diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 190bc00e3d..322aad46f4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,6 @@ # Core crate ownership. /codex-rs/core/ @openai/codex-core-agent-team +/codex-rs/ext/extension-api/ @openai/codex-core-agent-team # Keep ownership changes reviewed by the same team. /.github/CODEOWNERS @openai/codex-core-agent-team diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 3174ce5ecd..87434e1c68 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -887,7 +887,6 @@ "enum-as-inner_0.6.1": "{\"dependencies\":[{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", "enumflags2_0.7.12": "{\"dependencies\":[{\"name\":\"enumflags2_derive\",\"req\":\"=0.7.12\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.0\"}],\"features\":{\"std\":[]}}", "enumflags2_derive_0.7.12": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"derive\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", - "env-flags_0.1.1": "{\"dependencies\":[],\"features\":{}}", "env_filter_1.0.0": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.8\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}", "env_filter_1.0.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.29\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.12.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}", "env_home_0.1.0": "{\"dependencies\":[],\"features\":{}}", @@ -1483,6 +1482,7 @@ "serde_derive_1.0.228": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.81\"}],\"features\":{\"default\":[],\"deserialize_in_place\":[]}}", "serde_derive_internals_0.29.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}", "serde_html_form_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches2\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.11\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"form_urlencoded\",\"req\":\"^1.0.1\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.45.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"ryu\",\"optional\":true,\"req\":\"^1.0.9\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"}],\"features\":{\"default\":[\"ryu\",\"std\"],\"std\":[]}}", + "serde_ignored_0.1.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.110\"}],\"features\":{}}", "serde_json_1.0.149": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.11\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.2.3\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2.0.2\"},{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.194\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.10\"},{\"default_features\":false,\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_stacker\",\"req\":\"^0.1.8\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"},{\"name\":\"zmij\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[\"serde_core/alloc\"],\"arbitrary_precision\":[],\"default\":[\"std\"],\"float_roundtrip\":[],\"preserve_order\":[\"indexmap\",\"std\"],\"raw_value\":[],\"std\":[\"memchr/std\",\"serde_core/std\"],\"unbounded_depth\":[]}}", "serde_path_to_error_0.1.20": "{\"dependencies\":[{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"}],\"features\":{}}", "serde_repr_0.1.20": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"},{\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 58da81e835..fb8c5bc4d8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1922,7 +1922,6 @@ dependencies = [ "codex-state", "codex-thread-store", "codex-tools", - "codex-uds", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-cli", @@ -1931,13 +1930,16 @@ dependencies = [ "core_test_support", "flate2", "futures", + "hmac", "opentelemetry", "opentelemetry_sdk", "pretty_assertions", + "reqwest", "rmcp", "serde", "serde_json", "serial_test", + "sha2", "shlex", "tar", "tempfile", @@ -1968,6 +1970,8 @@ dependencies = [ "codex-exec-server", "codex-feedback", "codex-protocol", + "codex-uds", + "codex-utils-absolute-path", "codex-utils-rustls-provider", "futures", "pretty_assertions", @@ -2054,8 +2058,11 @@ dependencies = [ name = "codex-app-server-transport" version = "0.0.0" dependencies = [ + "anyhow", + "axum", "base64 0.22.1", "chrono", + "clap", "codex-api", "codex-app-server-protocol", "codex-config", @@ -2066,12 +2073,18 @@ dependencies = [ "codex-uds", "codex-utils-absolute-path", "codex-utils-rustls-provider", + "constant_time_eq 0.3.1", "futures", "gethostname", + "hmac", + "jsonwebtoken", + "owo-colors", "pretty_assertions", "serde", "serde_json", + "sha2", "tempfile", + "time", "tokio", "tokio-tungstenite", "tokio-util", @@ -2419,6 +2432,7 @@ dependencies = [ "prost 0.14.3", "schemars 0.8.22", "serde", + "serde_ignored", "serde_json", "serde_path_to_error", "sha2", @@ -2505,7 +2519,6 @@ dependencies = [ "codex-terminal-detection", "codex-test-binary-support", "codex-thread-store", - "codex-tool-api", "codex-tools", "codex-utils-absolute-path", "codex-utils-cache", @@ -2516,7 +2529,6 @@ dependencies = [ "codex-utils-path", "codex-utils-plugins", "codex-utils-pty", - "codex-utils-readiness", "codex-utils-stream-parser", "codex-utils-string", "codex-utils-template", @@ -2526,7 +2538,6 @@ dependencies = [ "ctor 0.6.3", "dirs", "dunce", - "env-flags", "eventsource-stream", "futures", "http 1.4.0", @@ -2741,6 +2752,7 @@ dependencies = [ "ctor 0.6.3", "futures", "pretty_assertions", + "prost 0.14.3", "reqwest", "serde", "serde_json", @@ -2808,7 +2820,7 @@ name = "codex-extension-api" version = "0.0.0" dependencies = [ "codex-protocol", - "codex-tool-api", + "codex-tools", ] [[package]] @@ -2907,7 +2919,6 @@ dependencies = [ "codex-core", "codex-extension-api", "codex-features", - "codex-protocol", "pretty_assertions", ] @@ -3128,6 +3139,26 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-memories-extension" +version = "0.0.0" +dependencies = [ + "codex-core", + "codex-extension-api", + "codex-features", + "codex-memories-read", + "codex-tools", + "codex-utils-absolute-path", + "codex-utils-output-truncation", + "pretty_assertions", + "schemars 0.8.22", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "codex-memories-mcp" version = "0.0.0" @@ -3688,6 +3719,7 @@ dependencies = [ "codex-protocol", "codex-rollout", "codex-state", + "codex-utils-path", "pretty_assertions", "serde", "serde_json", @@ -3698,15 +3730,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "codex-tool-api" -version = "0.0.0" -dependencies = [ - "pretty_assertions", - "serde_json", - "thiserror 2.0.18", -] - [[package]] name = "codex-tools" version = "0.0.0" @@ -3717,10 +3740,12 @@ dependencies = [ "codex-protocol", "codex-utils-absolute-path", "codex-utils-pty", + "codex-utils-string", "pretty_assertions", "rmcp", "serde", "serde_json", + "thiserror 2.0.18", "tracing", ] @@ -3780,6 +3805,7 @@ dependencies = [ "codex-utils-string", "codex-windows-sandbox", "color-eyre", + "core_test_support", "cpal", "crossterm", "derive_more 2.1.1", @@ -3830,6 +3856,7 @@ dependencies = [ "which 8.0.0", "windows-sys 0.52.0", "winsplit", + "wiremock", ] [[package]] @@ -5378,12 +5405,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "env-flags" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbfd0e7fc632dec5e6c9396a27bc9f9975b4e039720e1fd3e34021d3ce28c415" - [[package]] name = "env_filter" version = "1.0.0" @@ -5435,7 +5456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -11622,6 +11643,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5a21e98995..f5cfda7296 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -47,6 +47,7 @@ members = [ "ext/extension-api", "ext/guardian", "ext/git-attribution", + "ext/memories", "external-agent-migration", "external-agent-sessions", "keyring-store", @@ -108,7 +109,6 @@ members = [ "test-binary-support", "thread-manager-sample", "thread-store", - "tool-api", "uds", "codex-experimental-api-macros", "plugin", @@ -180,6 +180,7 @@ codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } codex-message-history = { path = "message-history" } +codex-memories-extension = { path = "ext/memories" } codex-memories-read = { path = "memories/read" } codex-memories-write = { path = "memories/write" } codex-mcp = { path = "codex-mcp" } @@ -209,7 +210,6 @@ codex-stdio-to-uds = { path = "stdio-to-uds" } codex-terminal-detection = { path = "terminal-detection" } codex-test-binary-support = { path = "test-binary-support" } codex-thread-store = { path = "thread-store" } -codex-tool-api = { path = "tool-api" } codex-tools = { path = "tools" } codex-tui = { path = "tui" } codex-uds = { path = "uds" } @@ -228,7 +228,6 @@ codex-utils-output-truncation = { path = "utils/output-truncation" } codex-utils-path = { path = "utils/path-utils" } codex-utils-plugins = { path = "utils/plugins" } codex-utils-pty = { path = "utils/pty" } -codex-utils-readiness = { path = "utils/readiness" } codex-utils-rustls-provider = { path = "utils/rustls-provider" } codex-utils-sandbox-summary = { path = "utils/sandbox-summary" } codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } @@ -266,6 +265,7 @@ chrono = "0.4.43" clap = "4" clap_complete = "4" color-eyre = "0.6.3" +constant_time_eq = "0.3.1" crossbeam-channel = "0.5.15" crypto_box = { version = "0.9.1", features = ["seal"] } crossterm = "0.28.1" @@ -280,7 +280,6 @@ dotenvy = "0.15.7" dunce = "1.0.4" ed25519-dalek = { version = "2.2.0", features = ["pkcs8"] } encoding_rs = "0.8.35" -env-flags = "0.1.1" env_logger = "0.11.9" eventsource-stream = "0.2.3" flate2 = "1.1.8" @@ -353,6 +352,7 @@ seccompiler = "0.5.0" semver = "1.0" sentry = "0.46.0" serde = "1" +serde_ignored = "0.1.14" serde_json = "1" serde_path_to_error = "0.1.20" serde_with = "3.17" @@ -472,6 +472,7 @@ unwrap_used = "deny" [workspace.metadata.cargo-shear] ignored = [ "codex-agent-graph-store", + "codex-memories-extension", "icu_provider", "openssl-sys", "codex-v8-poc", diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index ac284cbdfe..daa6ef661f 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -21,6 +21,8 @@ codex-core = { workspace = true } codex-exec-server = { workspace = true } codex-feedback = { workspace = true } codex-protocol = { workspace = true } +codex-uds = { workspace = true } +codex-utils-absolute-path = { workspace = true } codex-utils-rustls-provider = { workspace = true } futures = { workspace = true } serde = { workspace = true } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 3b386ff3ce..49a7a3b800 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -25,6 +25,7 @@ use std::io::Result as IoResult; use std::sync::Arc; use std::time::Duration; +pub use codex_app_server::app_server_control_socket_path; 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; @@ -61,6 +62,7 @@ use tracing::warn; pub use crate::remote::RemoteAppServerClient; pub use crate::remote::RemoteAppServerConnectArgs; +pub use crate::remote::RemoteAppServerEndpoint; /// Transitional access to core-only embedded app-server types. /// @@ -334,6 +336,8 @@ pub struct InProcessClientStartArgs { pub cli_overrides: Vec<(String, TomlValue)>, /// Loader override knobs used by config API paths. pub loader_overrides: LoaderOverrides, + /// Whether config API paths should reject unknown config fields. + pub strict_config: bool, /// Preloaded cloud requirements provider. pub cloud_requirements: CloudRequirementsLoader, /// Feedback sink used by app-server/core telemetry and logs. @@ -400,6 +404,7 @@ impl InProcessClientStartArgs { config: self.config, cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, + strict_config: self.strict_config, cloud_requirements: self.cloud_requirements, thread_config_loader, feedback: self.feedback, @@ -952,6 +957,8 @@ mod tests { use codex_app_server_protocol::ToolRequestUserInputQuestion; use codex_core::config::ConfigBuilder; use codex_core::init_state_db; + use codex_uds::UnixListener; + use codex_utils_absolute_path::AbsolutePathBuf; use futures::SinkExt; use futures::StreamExt; use pretty_assertions::assert_eq; @@ -961,6 +968,7 @@ mod tests { use tokio::net::TcpListener; use tokio::time::Duration; use tokio::time::timeout; + use tokio_tungstenite::accept_async; use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::handshake::server::Request as WebSocketRequest; @@ -1025,6 +1033,7 @@ mod tests { config, cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, @@ -1100,9 +1109,10 @@ mod tests { format!("ws://{addr}") } - async fn expect_remote_initialize( - websocket: &mut tokio_tungstenite::WebSocketStream, - ) { + async fn expect_remote_initialize(websocket: &mut tokio_tungstenite::WebSocketStream) + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { let JSONRPCMessage::Request(request) = read_websocket_message(websocket).await else { panic!("expected initialize request"); }; @@ -1123,9 +1133,12 @@ mod tests { assert_eq!(notification.method, "initialized"); } - async fn read_websocket_message( - websocket: &mut tokio_tungstenite::WebSocketStream, - ) -> JSONRPCMessage { + async fn read_websocket_message( + websocket: &mut tokio_tungstenite::WebSocketStream, + ) -> JSONRPCMessage + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { loop { let frame = websocket .next() @@ -1145,10 +1158,12 @@ mod tests { } } - async fn write_websocket_message( - websocket: &mut tokio_tungstenite::WebSocketStream, + async fn write_websocket_message( + websocket: &mut tokio_tungstenite::WebSocketStream, message: JSONRPCMessage, - ) { + ) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { websocket .send(Message::Text( serde_json::to_string(&message) @@ -1213,8 +1228,10 @@ mod tests { fn test_remote_connect_args(websocket_url: String) -> RemoteAppServerConnectArgs { RemoteAppServerConnectArgs { - websocket_url, - auth_token: None, + endpoint: RemoteAppServerEndpoint::WebSocket { + websocket_url, + auth_token: None, + }, client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, @@ -1453,6 +1470,64 @@ mod tests { client.shutdown().await.expect("shutdown should complete"); } + #[tokio::test] + async fn remote_unix_socket_typed_request_roundtrip_works() { + let socket_dir = TempDir::new().expect("socket dir"); + let socket_path = AbsolutePathBuf::from_absolute_path(socket_dir.path().join("codex.sock")) + .expect("socket path should resolve"); + let mut listener = UnixListener::bind(socket_path.as_path()) + .await + .expect("listener should bind"); + tokio::spawn(async move { + let stream = listener.accept().await.expect("accept should succeed"); + let mut websocket = accept_async(stream) + .await + .expect("websocket upgrade should succeed"); + expect_remote_initialize(&mut websocket).await; + let JSONRPCMessage::Request(request) = read_websocket_message(&mut websocket).await + else { + panic!("expected account/read request"); + }; + assert_eq!(request.method, "account/read"); + write_websocket_message( + &mut websocket, + JSONRPCMessage::Response(JSONRPCResponse { + id: request.id, + result: serde_json::to_value(GetAccountResponse { + account: None, + requires_openai_auth: false, + }) + .expect("response should serialize"), + }), + ) + .await; + websocket.close(None).await.expect("close should succeed"); + }); + let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: 8, + }) + .await + .expect("remote client should connect"); + + let response: GetAccountResponse = client + .request_typed(ClientRequest::GetAccount { + request_id: RequestId::Integer(1), + params: codex_app_server_protocol::GetAccountParams { + refresh_token: false, + }, + }) + .await + .expect("typed request should succeed"); + assert_eq!(response.account, None); + + client.shutdown().await.expect("shutdown should complete"); + } + #[tokio::test] async fn remote_typed_request_accepts_large_single_frame_response() { let padding = "x".repeat((17 << 20) + 1024); @@ -1514,8 +1589,15 @@ mod tests { ) .await; let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { - auth_token: Some(auth_token), - ..test_remote_connect_args(websocket_url) + endpoint: RemoteAppServerEndpoint::WebSocket { + websocket_url, + auth_token: Some(auth_token), + }, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: 8, }) .await .expect("remote client should connect"); @@ -1526,9 +1608,15 @@ mod tests { #[tokio::test] async fn remote_connect_rejects_non_loopback_ws_when_auth_configured() { let result = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { - websocket_url: "ws://example.com:4500".to_string(), - auth_token: Some("remote-bearer-token".to_string()), - ..test_remote_connect_args("ws://127.0.0.1:1".to_string()) + endpoint: RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://example.com:4500".to_string(), + auth_token: Some("remote-bearer-token".to_string()), + }, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: 8, }) .await; let err = match result { @@ -1706,13 +1794,8 @@ mod tests { }) .await; let mut client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { - websocket_url, - auth_token: None, - client_name: "codex-app-server-client-test".to_string(), - client_version: "0.0.0-test".to_string(), - experimental_api: true, - opt_out_notification_methods: Vec::new(), channel_capacity: 1, + ..test_remote_connect_args(websocket_url) }) .await .expect("remote client should connect"); @@ -2109,6 +2192,7 @@ mod tests { config: config.clone(), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, @@ -2149,6 +2233,7 @@ mod tests { config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index 4a4426a260..dd9c083e49 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -1,11 +1,13 @@ /* -This module implements the websocket-backed app-server client transport. +This module implements the remote app-server client transport. It owns the remote connection lifecycle, including the initialize/initialized handshake, JSON-RPC request/response routing, server-request resolution, and -notification streaming. The rest of the crate uses the same `AppServerEvent` -surface for both in-process and remote transports, so callers such as the TUI -can switch between them without changing their higher-level session logic. +notification streaming. Remote connections always carry WebSocket frames, over +either TCP WebSocket URLs or local Unix sockets. The rest of the crate uses the +same `AppServerEvent` surface for both in-process and remote transports, so +callers such as the TUI can switch between them without changing their +higher-level session logic. */ use std::collections::HashMap; @@ -35,17 +37,23 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +use codex_uds::UnixStream; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use futures::SinkExt; use futures::StreamExt; use serde::de::DeserializeOwned; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_tungstenite::MaybeTlsStream; use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::client_async_with_config; use tokio_tungstenite::connect_async_with_config; +use tokio_tungstenite::tungstenite::Error as TungsteniteError; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; @@ -57,18 +65,30 @@ use url::Url; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); const REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE: usize = 128 << 20; +// Tungstenite still needs an HTTP request URI for the WebSocket handshake; +// the bytes travel over the Unix socket, not TCP. +const UDS_WEBSOCKET_HANDSHAKE_URL: &str = "ws://localhost/rpc"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteAppServerEndpoint { + WebSocket { + websocket_url: String, + auth_token: Option, + }, + UnixSocket { + socket_path: AbsolutePathBuf, + }, +} #[derive(Debug, Clone)] pub struct RemoteAppServerConnectArgs { - pub websocket_url: String, - pub auth_token: Option, + pub endpoint: RemoteAppServerEndpoint, pub client_name: String, pub client_version: String, pub experimental_api: bool, pub opt_out_notification_methods: Vec, pub channel_capacity: usize, } - impl RemoteAppServerConnectArgs { fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { @@ -141,69 +161,39 @@ pub struct RemoteAppServerRequestHandle { impl RemoteAppServerClient { pub async fn connect(args: RemoteAppServerConnectArgs) -> IoResult { let channel_capacity = args.channel_capacity.max(1); - let websocket_url = args.websocket_url.clone(); - let url = Url::parse(&websocket_url).map_err(|err| { - IoError::new( - ErrorKind::InvalidInput, - format!("invalid websocket URL `{websocket_url}`: {err}"), - ) - })?; - if args.auth_token.is_some() && !websocket_url_supports_auth_token(&url) { - return Err(IoError::new( - ErrorKind::InvalidInput, - format!( - "remote auth tokens require `wss://` or loopback `ws://` URLs; got `{websocket_url}`" - ), - )); + let initialize_params = args.initialize_params(); + match args.endpoint { + RemoteAppServerEndpoint::WebSocket { + websocket_url, + auth_token, + } => { + let (endpoint, stream) = + connect_websocket_endpoint(websocket_url, auth_token).await?; + Self::connect_with_stream(channel_capacity, endpoint, stream, initialize_params) + .await + } + RemoteAppServerEndpoint::UnixSocket { socket_path } => { + let (endpoint, stream) = connect_unix_socket_endpoint(socket_path).await?; + Self::connect_with_stream(channel_capacity, endpoint, stream, initialize_params) + .await + } } - let mut request = url.as_str().into_client_request().map_err(|err| { - IoError::new( - ErrorKind::InvalidInput, - format!("invalid websocket URL `{websocket_url}`: {err}"), - ) - })?; - if let Some(auth_token) = args.auth_token.as_deref() { - let header_value = - HeaderValue::from_str(&format!("Bearer {auth_token}")).map_err(|err| { - IoError::new( - ErrorKind::InvalidInput, - format!("invalid remote authorization header value: {err}"), - ) - })?; - request.headers_mut().insert(AUTHORIZATION, header_value); - } - ensure_rustls_crypto_provider(); - // Remote resume responses can legitimately carry large thread histories. - // Keep a bounded cap, but raise it above tungstenite's 16 MiB frame default. - let websocket_config = WebSocketConfig::default() - .max_frame_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE)) - .max_message_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE)); - let stream = timeout( - CONNECT_TIMEOUT, - connect_async_with_config( - request, - Some(websocket_config), - /*disable_nagle*/ false, - ), - ) - .await - .map_err(|_| { - IoError::new( - ErrorKind::TimedOut, - format!("timed out connecting to remote app server at `{websocket_url}`"), - ) - })? - .map(|(stream, _response)| stream) - .map_err(|err| { - IoError::other(format!( - "failed to connect to remote app server at `{websocket_url}`: {err}" - )) - })?; + } + + async fn connect_with_stream( + channel_capacity: usize, + endpoint: String, + stream: WebSocketStream, + initialize_params: InitializeParams, + ) -> IoResult + where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { let mut stream = stream; let pending_events = initialize_remote_connection( &mut stream, - &websocket_url, - args.initialize_params(), + &endpoint, + initialize_params, INITIALIZE_TIMEOUT, ) .await?; @@ -235,13 +225,13 @@ impl RemoteAppServerClient { if let Err(err) = write_jsonrpc_message( &mut stream, JSONRPCMessage::Request(jsonrpc_request_from_client_request(*request)), - &websocket_url, + &endpoint, ) .await { let err_message = err.to_string(); let message = format!( - "remote app server at `{websocket_url}` write failed: {err_message}" + "remote app server at `{endpoint}` write failed: {err_message}" ); if let Some(response_tx) = pending_requests.remove(&request_id) { let _ = response_tx.send(Err(err)); @@ -262,7 +252,7 @@ impl RemoteAppServerClient { JSONRPCMessage::Notification( jsonrpc_notification_from_client_notification(notification), ), - &websocket_url, + &endpoint, ) .await; let _ = response_tx.send(result); @@ -278,7 +268,7 @@ impl RemoteAppServerClient { id: request_id, result, }), - &websocket_url, + &endpoint, ) .await; let _ = response_tx.send(result); @@ -294,16 +284,20 @@ impl RemoteAppServerClient { error, id: request_id, }), - &websocket_url, + &endpoint, ) .await; let _ = response_tx.send(result); } RemoteClientCommand::Shutdown { response_tx } => { - let close_result = stream.close(None).await.map_err(|err| { - IoError::other(format!( - "failed to close websocket app server `{websocket_url}`: {err}" - )) + let close_result = stream.close(None).await.or_else(|err| { + if websocket_close_error_is_already_closed(&err) { + Ok(()) + } else { + Err(IoError::other(format!( + "failed to close websocket app server `{endpoint}`: {err}" + ))) + } }); let _ = response_tx.send(close_result); break; @@ -364,13 +358,13 @@ impl RemoteAppServerClient { }, id: request_id, }), - &websocket_url, + &endpoint, ) .await { let err_message = reject_err.to_string(); let message = format!( - "remote app server at `{websocket_url}` write failed: {err_message}" + "remote app server at `{endpoint}` write failed: {err_message}" ); let _ = deliver_event( &event_tx, @@ -387,7 +381,7 @@ impl RemoteAppServerClient { } Err(err) => { let message = format!( - "remote app server at `{websocket_url}` sent invalid JSON-RPC: {err}" + "remote app server at `{endpoint}` sent invalid JSON-RPC: {err}" ); let _ = deliver_event( &event_tx, @@ -408,7 +402,7 @@ impl RemoteAppServerClient { .filter(|reason| !reason.is_empty()) .unwrap_or_else(|| "connection closed".to_string()); let message = format!( - "remote app server at `{websocket_url}` disconnected: {reason}" + "remote app server at `{endpoint}` disconnected: {reason}" ); let _ = deliver_event( &event_tx, @@ -428,7 +422,7 @@ impl RemoteAppServerClient { | Some(Ok(Message::Frame(_))) => {} Some(Err(err)) => { let message = format!( - "remote app server at `{websocket_url}` transport failed: {err}" + "remote app server at `{endpoint}` transport failed: {err}" ); let _ = deliver_event( &event_tx, @@ -441,7 +435,7 @@ impl RemoteAppServerClient { } None => { let message = format!( - "remote app server at `{websocket_url}` closed the connection" + "remote app server at `{endpoint}` closed the connection" ); let _ = deliver_event( &event_tx, @@ -678,12 +672,131 @@ impl RemoteAppServerRequestHandle { } } -async fn initialize_remote_connection( - stream: &mut WebSocketStream>, - websocket_url: &str, +async fn connect_websocket_endpoint( + websocket_url: String, + auth_token: Option, +) -> IoResult<(String, WebSocketStream>)> { + let url = Url::parse(&websocket_url).map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid websocket URL `{websocket_url}`: {err}"), + ) + })?; + if auth_token.is_some() && !websocket_url_supports_auth_token(&url) { + return Err(IoError::new( + ErrorKind::InvalidInput, + format!( + "remote auth tokens require `wss://` or loopback `ws://` URLs; got `{websocket_url}`" + ), + )); + } + + let mut request = url.as_str().into_client_request().map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid websocket URL `{websocket_url}`: {err}"), + ) + })?; + if let Some(auth_token) = auth_token.as_deref() { + let header_value = + HeaderValue::from_str(&format!("Bearer {auth_token}")).map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid remote authorization header value: {err}"), + ) + })?; + request.headers_mut().insert(AUTHORIZATION, header_value); + } + + ensure_rustls_crypto_provider(); + let websocket_config = remote_websocket_config(); + let stream = timeout( + CONNECT_TIMEOUT, + connect_async_with_config( + request, + Some(websocket_config), + /*disable_nagle*/ false, + ), + ) + .await + .map_err(|_| { + IoError::new( + ErrorKind::TimedOut, + format!("timed out connecting to remote app server at `{websocket_url}`"), + ) + })? + .map(|(stream, _response)| stream) + .map_err(|err| { + IoError::other(format!( + "failed to connect to remote app server at `{websocket_url}`: {err}" + )) + })?; + + Ok((websocket_url, stream)) +} + +async fn connect_unix_socket_endpoint( + socket_path: AbsolutePathBuf, +) -> IoResult<(String, WebSocketStream)> { + let endpoint = format!("unix://{}", socket_path.display()); + let request = UDS_WEBSOCKET_HANDSHAKE_URL + .into_client_request() + .map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid UDS websocket handshake URL: {err}"), + ) + })?; + let stream = timeout(CONNECT_TIMEOUT, UnixStream::connect(socket_path.as_path())) + .await + .map_err(|_| { + IoError::new( + ErrorKind::TimedOut, + format!("timed out connecting to remote app server at `{endpoint}`"), + ) + })? + .map_err(|err| { + IoError::other(format!( + "failed to connect to remote app server at `{endpoint}`: {err}" + )) + })?; + let websocket_config = remote_websocket_config(); + let stream = timeout( + CONNECT_TIMEOUT, + client_async_with_config(request, stream, Some(websocket_config)), + ) + .await + .map_err(|_| { + IoError::new( + ErrorKind::TimedOut, + format!("timed out upgrading remote app server at `{endpoint}`"), + ) + })? + .map(|(stream, _response)| stream) + .map_err(|err| { + IoError::other(format!( + "failed to upgrade remote app server at `{endpoint}`: {err}" + )) + })?; + + Ok((endpoint, stream)) +} + +fn remote_websocket_config() -> WebSocketConfig { + WebSocketConfig::default() + .max_frame_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE)) + .max_message_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE)) +} + +async fn initialize_remote_connection( + stream: &mut WebSocketStream, + endpoint: &str, params: InitializeParams, initialize_timeout: Duration, -) -> IoResult> { +) -> IoResult> +where + S: AsyncRead + AsyncWrite + Unpin, +{ let initialize_request_id = RequestId::String("initialize".to_string()); let mut pending_events = Vec::new(); write_jsonrpc_message( @@ -694,7 +807,7 @@ async fn initialize_remote_connection( params, }, )), - websocket_url, + endpoint, ) .await?; @@ -704,7 +817,7 @@ async fn initialize_remote_connection( Some(Ok(Message::Text(text))) => { let message = serde_json::from_str::(&text).map_err(|err| { IoError::other(format!( - "remote app server at `{websocket_url}` sent invalid initialize response: {err}" + "remote app server at `{endpoint}` sent invalid initialize response: {err}" )) })?; match message { @@ -713,7 +826,7 @@ async fn initialize_remote_connection( } JSONRPCMessage::Error(error) if error.id == initialize_request_id => { break Err(IoError::other(format!( - "remote app server at `{websocket_url}` rejected initialize: {}", + "remote app server at `{endpoint}` rejected initialize: {}", error.error.message ))); } @@ -743,7 +856,7 @@ async fn initialize_remote_connection( }, id: request_id, }), - websocket_url, + endpoint, ) .await?; } @@ -765,19 +878,19 @@ async fn initialize_remote_connection( break Err(IoError::new( ErrorKind::ConnectionAborted, format!( - "remote app server at `{websocket_url}` closed during initialize: {reason}" + "remote app server at `{endpoint}` closed during initialize: {reason}" ), )); } Some(Err(err)) => { break Err(IoError::other(format!( - "remote app server at `{websocket_url}` transport failed during initialize: {err}" + "remote app server at `{endpoint}` transport failed during initialize: {err}" ))); } None => { break Err(IoError::new( ErrorKind::UnexpectedEof, - format!("remote app server at `{websocket_url}` closed during initialize"), + format!("remote app server at `{endpoint}` closed during initialize"), )); } } @@ -787,7 +900,7 @@ async fn initialize_remote_connection( .map_err(|_| { IoError::new( ErrorKind::TimedOut, - format!("timed out waiting for initialize response from `{websocket_url}`"), + format!("timed out waiting for initialize response from `{endpoint}`"), ) })??; @@ -796,7 +909,7 @@ async fn initialize_remote_connection( JSONRPCMessage::Notification(jsonrpc_notification_from_client_notification( ClientNotification::Initialized, )), - websocket_url, + endpoint, ) .await?; @@ -850,21 +963,35 @@ fn jsonrpc_notification_from_client_notification( } } -async fn write_jsonrpc_message( - stream: &mut WebSocketStream>, +async fn write_jsonrpc_message( + stream: &mut WebSocketStream, message: JSONRPCMessage, - websocket_url: &str, -) -> IoResult<()> { + endpoint: &str, +) -> IoResult<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ let payload = serde_json::to_string(&message).map_err(IoError::other)?; stream .send(Message::Text(payload.into())) .await .map_err(|err| { IoError::other(format!( - "failed to write websocket message to `{websocket_url}`: {err}" + "failed to write websocket message to `{endpoint}`: {err}" )) }) } + +fn websocket_close_error_is_already_closed(err: &TungsteniteError) -> bool { + match err { + TungsteniteError::ConnectionClosed | TungsteniteError::AlreadyClosed => true, + TungsteniteError::Io(err) => matches!( + err.kind(), + ErrorKind::BrokenPipe | ErrorKind::ConnectionReset | ErrorKind::NotConnected + ), + _ => false, + } +} #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6351993046..a6fe99b35e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2015,6 +2015,17 @@ ], "type": "object" }, + "PluginShareCheckoutParams": { + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, "PluginShareDeleteParams": { "properties": { "remotePluginId": { @@ -5075,6 +5086,30 @@ "title": "Plugin/share/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/checkout" + ], + "title": "Plugin/share/checkoutRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareCheckoutParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/checkoutRequest", + "type": "object" + }, { "properties": { "id": { 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 9934e36940..667607d43e 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 @@ -877,6 +877,30 @@ "title": "Plugin/share/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/share/checkout" + ], + "title": "Plugin/share/checkoutRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginShareCheckoutParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/checkoutRequest", + "type": "object" + }, { "properties": { "id": { @@ -7540,6 +7564,12 @@ }, "ConfigRequirements": { "properties": { + "allowManagedHooksOnly": { + "type": [ + "boolean", + "null" + ] + }, "allowedApprovalPolicies": { "items": { "$ref": "#/definitions/v2/AskForApproval" @@ -12262,6 +12292,58 @@ "title": "PluginReadResponse", "type": "object" }, + "PluginShareCheckoutParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "title": "PluginShareCheckoutParams", + "type": "object" + }, + "PluginShareCheckoutResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceName": { + "type": "string" + }, + "marketplacePath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "pluginId": { + "type": "string" + }, + "pluginName": { + "type": "string" + }, + "pluginPath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "marketplaceName", + "marketplacePath", + "pluginId", + "pluginName", + "pluginPath", + "remotePluginId" + ], + "title": "PluginShareCheckoutResponse", + "type": "object" + }, "PluginShareContext": { "properties": { "creatorAccountUserId": { @@ -12289,6 +12371,14 @@ "remotePluginId": { "type": "string" }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, "sharePrincipals": { "items": { "$ref": "#/definitions/v2/PluginSharePrincipal" @@ -12705,6 +12795,14 @@ }, "type": "array" }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, 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 1926b22091..c1a99eddda 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 @@ -1617,6 +1617,30 @@ "title": "Plugin/share/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/checkout" + ], + "title": "Plugin/share/checkoutRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareCheckoutParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/checkoutRequest", + "type": "object" + }, { "properties": { "id": { @@ -3929,6 +3953,12 @@ }, "ConfigRequirements": { "properties": { + "allowManagedHooksOnly": { + "type": [ + "boolean", + "null" + ] + }, "allowedApprovalPolicies": { "items": { "$ref": "#/definitions/AskForApproval" @@ -8811,6 +8841,58 @@ "title": "PluginReadResponse", "type": "object" }, + "PluginShareCheckoutParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "title": "PluginShareCheckoutParams", + "type": "object" + }, + "PluginShareCheckoutResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceName": { + "type": "string" + }, + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "pluginId": { + "type": "string" + }, + "pluginName": { + "type": "string" + }, + "pluginPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "marketplaceName", + "marketplacePath", + "pluginId", + "pluginName", + "pluginPath", + "remotePluginId" + ], + "title": "PluginShareCheckoutResponse", + "type": "object" + }, "PluginShareContext": { "properties": { "creatorAccountUserId": { @@ -8838,6 +8920,14 @@ "remotePluginId": { "type": "string" }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" @@ -9254,6 +9344,14 @@ }, "type": "array" }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index b229d8ef94..41bd337dec 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -62,6 +62,12 @@ }, "ConfigRequirements": { "properties": { + "allowManagedHooksOnly": { + "type": [ + "boolean", + "null" + ] + }, "allowedApprovalPolicies": { "items": { "$ref": "#/definitions/AskForApproval" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index 72b1f7c9aa..d756f61a68 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -259,6 +259,14 @@ "remotePluginId": { "type": "string" }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" @@ -449,6 +457,14 @@ }, "type": "array" }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 945b4897cb..0c9f897dc9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -313,6 +313,14 @@ "remotePluginId": { "type": "string" }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" @@ -503,6 +511,14 @@ }, "type": "array" }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareCheckoutParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareCheckoutParams.json new file mode 100644 index 0000000000..dc7e2bdfe6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareCheckoutParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "title": "PluginShareCheckoutParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareCheckoutResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareCheckoutResponse.json new file mode 100644 index 0000000000..ace02c59ea --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareCheckoutResponse.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "properties": { + "marketplaceName": { + "type": "string" + }, + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "pluginId": { + "type": "string" + }, + "pluginName": { + "type": "string" + }, + "pluginPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "marketplaceName", + "marketplacePath", + "pluginId", + "pluginName", + "pluginPath", + "remotePluginId" + ], + "title": "PluginShareCheckoutResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index ed2beacada..7cffdeab9a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -194,6 +194,14 @@ "remotePluginId": { "type": "string" }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" @@ -405,6 +413,14 @@ }, "type": "array" }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index a12185b501..28371c7106 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -45,6 +45,7 @@ import type { ModelProviderCapabilitiesReadParams } from "./v2/ModelProviderCapa import type { PluginInstallParams } from "./v2/PluginInstallParams"; import type { PluginListParams } from "./v2/PluginListParams"; import type { PluginReadParams } from "./v2/PluginReadParams"; +import type { PluginShareCheckoutParams } from "./v2/PluginShareCheckoutParams"; import type { PluginShareDeleteParams } from "./v2/PluginShareDeleteParams"; import type { PluginShareListParams } from "./v2/PluginShareListParams"; import type { PluginShareSaveParams } from "./v2/PluginShareSaveParams"; @@ -79,4 +80,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 47a99453fe..1b0625e5e1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -6,4 +6,4 @@ import type { AskForApproval } from "./AskForApproval"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareCheckoutParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareCheckoutParams.ts new file mode 100644 index 0000000000..5bd14aa608 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareCheckoutParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginShareCheckoutParams = { remotePluginId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareCheckoutResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareCheckoutResponse.ts new file mode 100644 index 0000000000..d27af9e2a5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareCheckoutResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PluginShareCheckoutResponse = { remotePluginId: string, pluginId: string, pluginName: string, pluginPath: AbsolutePathBuf, marketplaceName: string, marketplacePath: AbsolutePathBuf, remoteVersion: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts index 86d610bf5a..99b8f46601 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts @@ -4,4 +4,8 @@ import type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; import type { PluginSharePrincipal } from "./PluginSharePrincipal"; -export type PluginShareContext = { remotePluginId: string, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array | null, }; +export type PluginShareContext = { remotePluginId: string, +/** + * Version of the remote shared plugin release when available. + */ +remoteVersion: string | null, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts index 8fae7ae865..268349cb9b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -12,7 +12,11 @@ export type PluginSummary = { id: string, /** * Backend remote plugin identifier when available. */ -remotePluginId: string | null, name: string, +remotePluginId: string | null, +/** + * Version of the locally materialized plugin package when available. + */ +localVersion: string | null, name: string, /** * Remote sharing context associated with this plugin when available. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index a6b961366e..ab6eaefb5a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -277,6 +277,8 @@ export type { PluginListResponse } from "./PluginListResponse"; export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; export type { PluginReadParams } from "./PluginReadParams"; export type { PluginReadResponse } from "./PluginReadResponse"; +export type { PluginShareCheckoutParams } from "./PluginShareCheckoutParams"; +export type { PluginShareCheckoutResponse } from "./PluginShareCheckoutResponse"; export type { PluginShareContext } from "./PluginShareContext"; export type { PluginShareDeleteParams } from "./PluginShareDeleteParams"; export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ae00b08b73..b70af1a22b 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -621,12 +621,12 @@ client_request_definitions! { }, PluginList => "plugin/list" { params: v2::PluginListParams, - serialization: global_shared_read("config"), + serialization: global_shared_read("plugin-read"), response: v2::PluginListResponse, }, PluginRead => "plugin/read" { params: v2::PluginReadParams, - serialization: global("config"), + serialization: global_shared_read("plugin-read"), response: v2::PluginReadResponse, }, PluginSkillRead => "plugin/skill/read" { @@ -649,6 +649,11 @@ client_request_definitions! { serialization: global("config"), response: v2::PluginShareListResponse, }, + PluginShareCheckout => "plugin/share/checkout" { + params: v2::PluginShareCheckoutParams, + serialization: global("config"), + response: v2::PluginShareCheckoutResponse, + }, PluginShareDelete => "plugin/share/delete" { params: v2::PluginShareDeleteParams, serialization: global("config"), @@ -1685,7 +1690,24 @@ mod tests { }; assert_eq!( plugin_list.serialization_scope(), - Some(ClientRequestSerializationScope::GlobalSharedRead("config")) + Some(ClientRequestSerializationScope::GlobalSharedRead( + "plugin-read" + )) + ); + + let plugin_read = ClientRequest::PluginRead { + request_id: request_id(), + params: v2::PluginReadParams { + marketplace_path: Some(absolute_path("/tmp/marketplace")), + remote_marketplace_name: None, + plugin_name: "plugin-a".to_string(), + }, + }; + assert_eq!( + plugin_read.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead( + "plugin-read" + )) ); let plugin_uninstall = ClientRequest::PluginUninstall { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 35e487e45e..2c0e2df8e5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -357,6 +357,7 @@ pub struct ConfigRequirements { pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, pub allowed_web_search_modes: Option>, + pub allow_managed_hooks_only: Option, pub feature_requirements: Option>, #[experimental("configRequirements/read.hooks")] pub hooks: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 82568446b7..3ffab53218 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -242,6 +242,26 @@ pub struct PluginShareListResponse { pub data: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareCheckoutParams { + pub remote_plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareCheckoutResponse { + pub remote_plugin_id: String, + pub plugin_id: String, + pub plugin_name: String, + pub plugin_path: AbsolutePathBuf, + pub marketplace_name: String, + pub marketplace_path: AbsolutePathBuf, + pub remote_version: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -540,6 +560,9 @@ pub struct PluginSummary { pub id: String, /// Backend remote plugin identifier when available. pub remote_plugin_id: Option, + /// Version of the locally materialized plugin package when available. + #[serde(default)] + pub local_version: Option, pub name: String, /// Remote sharing context associated with this plugin when available. pub share_context: Option, @@ -561,6 +584,9 @@ pub struct PluginSummary { #[ts(export_to = "v2/")] pub struct PluginShareContext { pub remote_plugin_id: String, + /// Version of the remote shared plugin release when available. + #[serde(default)] + pub remote_version: Option, pub discoverability: Option, pub share_url: Option, pub creator_account_user_id: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 73b22cecb4..f7041cc721 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1688,6 +1688,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, enforce_residency: None, @@ -2986,6 +2987,52 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { PluginShareListParams {}, ); + assert_eq!( + serde_json::to_value(PluginShareCheckoutParams { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + }), + ); + + let plugin_path = if cfg!(windows) { + r"C:\Users\me\plugins\gmail" + } else { + "/Users/me/plugins/gmail" + }; + let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap(); + let plugin_path_json = plugin_path.as_path().display().to_string(); + let marketplace_path = if cfg!(windows) { + r"C:\Users\me\.agents\plugins\marketplace.json" + } else { + "/Users/me/.agents/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginShareCheckoutResponse { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + plugin_id: "gmail@codex-curated".to_string(), + plugin_name: "gmail".to_string(), + plugin_path, + marketplace_name: "codex-curated".to_string(), + marketplace_path, + remote_version: Some("1.2.3".to_string()), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "pluginId": "gmail@codex-curated", + "pluginName": "gmail", + "pluginPath": plugin_path_json, + "marketplaceName": "codex-curated", + "marketplacePath": marketplace_path_json, + "remoteVersion": "1.2.3", + }), + ); + assert_eq!( serde_json::to_value(PluginShareDeleteParams { remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), @@ -3007,6 +3054,7 @@ fn plugin_share_list_response_serializes_share_items() { remote_plugin_id: Some( "plugins~Plugin_00000000000000000000000000000000".to_string(), ), + local_version: None, name: "gmail".to_string(), share_context: None, source: PluginSource::Remote, @@ -3027,6 +3075,7 @@ fn plugin_share_list_response_serializes_share_items() { "plugin": { "id": "gmail@chatgpt-global", "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "localVersion": null, "name": "gmail", "shareContext": null, "source": { "type": "remote" }, @@ -3059,6 +3108,7 @@ fn plugin_summary_defaults_missing_availability_to_available() { .unwrap(); assert_eq!(summary.availability, PluginAvailability::Available); + assert_eq!(summary.local_version, None); assert_eq!(summary.share_context, None); } diff --git a/codex-rs/app-server-transport/Cargo.toml b/codex-rs/app-server-transport/Cargo.toml index 523c6ada52..175890962e 100644 --- a/codex-rs/app-server-transport/Cargo.toml +++ b/codex-rs/app-server-transport/Cargo.toml @@ -13,7 +13,15 @@ doctest = false workspace = true [dependencies] +anyhow = { workspace = true } +axum = { workspace = true, default-features = false, features = [ + "http1", + "json", + "tokio", + "ws", +] } base64 = { workspace = true } +clap = { workspace = true, features = ["derive"] } codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-core = { workspace = true } @@ -23,10 +31,16 @@ codex-state = { workspace = true } codex-uds = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-rustls-provider = { workspace = true } +constant_time_eq = { workspace = true } futures = { workspace = true } gethostname = { workspace = true } +hmac = { workspace = true } +jsonwebtoken = { workspace = true } +owo-colors = { workspace = true, features = ["supports-colors"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } +time = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/app-server-transport/src/lib.rs b/codex-rs/app-server-transport/src/lib.rs index 5d0adb642e..e3f39f60ce 100644 --- a/codex-rs/app-server-transport/src/lib.rs +++ b/codex-rs/app-server-transport/src/lib.rs @@ -14,6 +14,8 @@ pub use transport::RemoteControlHandle; pub use transport::RemoteControlStartConfig; pub use transport::TransportEvent; pub use transport::app_server_control_socket_path; +pub use transport::auth; pub use transport::start_control_socket_acceptor; pub use transport::start_remote_control; pub use transport::start_stdio_connection; +pub use transport::start_websocket_acceptor; diff --git a/codex-rs/app-server-transport/src/transport/auth.rs b/codex-rs/app-server-transport/src/transport/auth.rs new file mode 100644 index 0000000000..eeccf21ed3 --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/auth.rs @@ -0,0 +1,751 @@ +use anyhow::Context; +use axum::http::HeaderMap; +use axum::http::StatusCode; +use axum::http::header::AUTHORIZATION; +use clap::Args; +use clap::ValueEnum; +use codex_utils_absolute_path::AbsolutePathBuf; +use constant_time_eq::constant_time_eq_32; +use jsonwebtoken::Algorithm; +use jsonwebtoken::DecodingKey; +use jsonwebtoken::Validation; +use jsonwebtoken::decode; +use serde::Deserialize; +use sha2::Digest; +use sha2::Sha256; +use std::io; +use std::io::ErrorKind; +use std::net::SocketAddr; +use std::path::Path; +use std::path::PathBuf; +use time::OffsetDateTime; + +const DEFAULT_MAX_CLOCK_SKEW_SECONDS: u64 = 30; +const MIN_SIGNED_BEARER_SECRET_BYTES: usize = 32; +const INVALID_AUTHORIZATION_HEADER_MESSAGE: &str = "invalid authorization header"; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Args)] +pub struct AppServerWebsocketAuthArgs { + /// Websocket auth mode for non-loopback listeners. + #[arg(long = "ws-auth", value_name = "MODE", value_enum)] + pub ws_auth: Option, + + /// Absolute path to the capability-token file. + #[arg(long = "ws-token-file", value_name = "PATH")] + pub ws_token_file: Option, + + /// Hex-encoded SHA-256 digest of the capability token. + #[arg(long = "ws-token-sha256", value_name = "HEX")] + pub ws_token_sha256: Option, + + /// Absolute path to the shared secret file for signed JWT bearer tokens. + #[arg(long = "ws-shared-secret-file", value_name = "PATH")] + pub ws_shared_secret_file: Option, + + /// Expected issuer for signed JWT bearer tokens. + #[arg(long = "ws-issuer", value_name = "ISSUER")] + pub ws_issuer: Option, + + /// Expected audience for signed JWT bearer tokens. + #[arg(long = "ws-audience", value_name = "AUDIENCE")] + pub ws_audience: Option, + + /// Maximum clock skew when validating signed JWT bearer tokens. + #[arg(long = "ws-max-clock-skew-seconds", value_name = "SECONDS")] + pub ws_max_clock_skew_seconds: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum WebsocketAuthCliMode { + CapabilityToken, + SignedBearerToken, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AppServerWebsocketAuthSettings { + pub config: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppServerWebsocketAuthConfig { + CapabilityToken { + source: AppServerWebsocketCapabilityTokenSource, + }, + SignedBearerToken { + shared_secret_file: AbsolutePathBuf, + issuer: Option, + audience: Option, + max_clock_skew_seconds: u64, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppServerWebsocketCapabilityTokenSource { + TokenFile { token_file: AbsolutePathBuf }, + TokenSha256 { token_sha256: [u8; 32] }, +} + +#[derive(Clone, Debug, Default)] +pub struct WebsocketAuthPolicy { + pub(crate) mode: Option, +} + +#[derive(Clone, Debug)] +pub(crate) enum WebsocketAuthMode { + CapabilityToken { + token_sha256: [u8; 32], + }, + SignedBearerToken { + shared_secret: Vec, + issuer: Option, + audience: Option, + max_clock_skew_seconds: i64, + }, +} + +#[derive(Debug)] +pub(crate) struct WebsocketAuthError { + status_code: StatusCode, + message: &'static str, +} + +#[derive(Deserialize)] +struct JwtClaims { + exp: i64, + nbf: Option, + iss: Option, + aud: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum JwtAudienceClaim { + Single(String), + Multiple(Vec), +} + +impl WebsocketAuthError { + pub(crate) fn status_code(&self) -> StatusCode { + self.status_code + } + + pub(crate) fn message(&self) -> &'static str { + self.message + } +} + +impl AppServerWebsocketAuthArgs { + pub fn try_into_settings(self) -> anyhow::Result { + let normalize = |value: Option| { + value.and_then(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) + }; + + let config = match self.ws_auth { + Some(WebsocketAuthCliMode::CapabilityToken) => { + if self.ws_shared_secret_file.is_some() + || self.ws_issuer.is_some() + || self.ws_audience.is_some() + || self.ws_max_clock_skew_seconds.is_some() + { + anyhow::bail!( + "`--ws-shared-secret-file`, `--ws-issuer`, `--ws-audience`, and `--ws-max-clock-skew-seconds` require `--ws-auth signed-bearer-token`" + ); + } + let source = match (self.ws_token_file, self.ws_token_sha256) { + (Some(_), Some(_)) => { + anyhow::bail!( + "`--ws-token-file` and `--ws-token-sha256` are mutually exclusive" + ); + } + (Some(token_file), None) => { + AppServerWebsocketCapabilityTokenSource::TokenFile { + token_file: absolute_path_arg("--ws-token-file", token_file)?, + } + } + (None, Some(token_sha256)) => { + AppServerWebsocketCapabilityTokenSource::TokenSha256 { + token_sha256: sha256_digest_arg("--ws-token-sha256", &token_sha256)?, + } + } + (None, None) => { + anyhow::bail!( + "`--ws-token-file` or `--ws-token-sha256` is required when `--ws-auth capability-token` is set" + ); + } + }; + Some(AppServerWebsocketAuthConfig::CapabilityToken { source }) + } + Some(WebsocketAuthCliMode::SignedBearerToken) => { + if self.ws_token_file.is_some() || self.ws_token_sha256.is_some() { + anyhow::bail!( + "`--ws-token-file` and `--ws-token-sha256` require `--ws-auth capability-token`, not `signed-bearer-token`" + ); + } + let shared_secret_file = self.ws_shared_secret_file.context( + "`--ws-shared-secret-file` is required when `--ws-auth signed-bearer-token` is set", + )?; + Some(AppServerWebsocketAuthConfig::SignedBearerToken { + shared_secret_file: absolute_path_arg( + "--ws-shared-secret-file", + shared_secret_file, + )?, + issuer: normalize(self.ws_issuer), + audience: normalize(self.ws_audience), + max_clock_skew_seconds: self + .ws_max_clock_skew_seconds + .unwrap_or(DEFAULT_MAX_CLOCK_SKEW_SECONDS), + }) + } + None => { + if self.ws_token_file.is_some() + || self.ws_token_sha256.is_some() + || self.ws_shared_secret_file.is_some() + || self.ws_issuer.is_some() + || self.ws_audience.is_some() + || self.ws_max_clock_skew_seconds.is_some() + { + anyhow::bail!( + "websocket auth flags require `--ws-auth capability-token` or `--ws-auth signed-bearer-token`" + ); + } + None + } + }; + + Ok(AppServerWebsocketAuthSettings { config }) + } +} + +pub fn policy_from_settings( + settings: &AppServerWebsocketAuthSettings, +) -> io::Result { + let mode = match settings.config.as_ref() { + Some(AppServerWebsocketAuthConfig::CapabilityToken { source }) => match source { + AppServerWebsocketCapabilityTokenSource::TokenFile { token_file } => { + let token = read_trimmed_secret(token_file.as_ref())?; + Some(WebsocketAuthMode::CapabilityToken { + token_sha256: sha256_digest(token.as_bytes()), + }) + } + AppServerWebsocketCapabilityTokenSource::TokenSha256 { token_sha256 } => { + Some(WebsocketAuthMode::CapabilityToken { + token_sha256: *token_sha256, + }) + } + }, + Some(AppServerWebsocketAuthConfig::SignedBearerToken { + shared_secret_file, + issuer, + audience, + max_clock_skew_seconds, + }) => { + let shared_secret = read_trimmed_secret(shared_secret_file.as_ref())?.into_bytes(); + validate_signed_bearer_secret(shared_secret_file.as_ref(), &shared_secret)?; + let max_clock_skew_seconds = i64::try_from(*max_clock_skew_seconds).map_err(|_| { + io::Error::new( + ErrorKind::InvalidInput, + "websocket auth clock skew must fit in a signed 64-bit integer", + ) + })?; + Some(WebsocketAuthMode::SignedBearerToken { + shared_secret, + issuer: issuer.clone(), + audience: audience.clone(), + max_clock_skew_seconds, + }) + } + None => None, + }; + + Ok(WebsocketAuthPolicy { mode }) +} + +pub(crate) fn is_unauthenticated_non_loopback_listener( + bind_address: SocketAddr, + policy: &WebsocketAuthPolicy, +) -> bool { + !bind_address.ip().is_loopback() && policy.mode.is_none() +} + +pub(crate) fn authorize_upgrade( + headers: &HeaderMap, + policy: &WebsocketAuthPolicy, +) -> Result<(), WebsocketAuthError> { + let Some(mode) = policy.mode.as_ref() else { + return Ok(()); + }; + + let token = bearer_token_from_headers(headers)?; + match mode { + WebsocketAuthMode::CapabilityToken { token_sha256 } => { + let actual_sha256 = sha256_digest(token.as_bytes()); + if constant_time_eq_32(token_sha256, &actual_sha256) { + Ok(()) + } else { + Err(unauthorized("invalid websocket bearer token")) + } + } + WebsocketAuthMode::SignedBearerToken { + shared_secret, + issuer, + audience, + max_clock_skew_seconds, + } => verify_signed_bearer_token( + token, + shared_secret, + issuer.as_deref(), + audience.as_deref(), + *max_clock_skew_seconds, + ), + } +} + +fn verify_signed_bearer_token( + token: &str, + shared_secret: &[u8], + issuer: Option<&str>, + audience: Option<&str>, + max_clock_skew_seconds: i64, +) -> Result<(), WebsocketAuthError> { + let claims = decode_jwt_claims(token, shared_secret)?; + validate_jwt_claims(&claims, issuer, audience, max_clock_skew_seconds) +} + +fn decode_jwt_claims(token: &str, shared_secret: &[u8]) -> Result { + let mut validation = Validation::new(Algorithm::HS256); + validation.required_spec_claims.clear(); + validation.validate_exp = false; + validation.validate_nbf = false; + validation.validate_aud = false; + + decode::(token, &DecodingKey::from_secret(shared_secret), &validation) + .map(|token_data| token_data.claims) + .map_err(|_| unauthorized("invalid websocket jwt")) +} + +fn validate_jwt_claims( + claims: &JwtClaims, + issuer: Option<&str>, + audience: Option<&str>, + max_clock_skew_seconds: i64, +) -> Result<(), WebsocketAuthError> { + let now = OffsetDateTime::now_utc().unix_timestamp(); + if now > claims.exp.saturating_add(max_clock_skew_seconds) { + return Err(unauthorized("expired websocket jwt")); + } + if let Some(nbf) = claims.nbf + && now < nbf.saturating_sub(max_clock_skew_seconds) + { + return Err(unauthorized("websocket jwt is not valid yet")); + } + if let Some(expected_issuer) = issuer + && claims.iss.as_deref() != Some(expected_issuer) + { + return Err(unauthorized("websocket jwt issuer mismatch")); + } + if let Some(expected_audience) = audience + && !audience_matches(claims.aud.as_ref(), expected_audience) + { + return Err(unauthorized("websocket jwt audience mismatch")); + } + + Ok(()) +} + +fn audience_matches(audience: Option<&JwtAudienceClaim>, expected_audience: &str) -> bool { + match audience { + Some(JwtAudienceClaim::Single(actual)) => actual == expected_audience, + Some(JwtAudienceClaim::Multiple(actual)) => { + actual.iter().any(|audience| audience == expected_audience) + } + None => false, + } +} + +fn bearer_token_from_headers(headers: &HeaderMap) -> Result<&str, WebsocketAuthError> { + let raw_header = headers + .get(AUTHORIZATION) + .ok_or_else(|| unauthorized("missing websocket bearer token"))?; + let header = raw_header + .to_str() + .map_err(|_| unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE))?; + let Some((scheme, token)) = header.split_once(' ') else { + return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE)); + }; + if !scheme.eq_ignore_ascii_case("Bearer") { + return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE)); + } + let token = token.trim(); + if token.is_empty() { + return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE)); + } + Ok(token) +} + +fn validate_signed_bearer_secret(path: &Path, shared_secret: &[u8]) -> io::Result<()> { + if shared_secret.len() < MIN_SIGNED_BEARER_SECRET_BYTES { + return Err(io::Error::new( + ErrorKind::InvalidInput, + format!( + "signed websocket bearer secret {} must be at least {MIN_SIGNED_BEARER_SECRET_BYTES} bytes", + path.display() + ), + )); + } + Ok(()) +} + +fn read_trimmed_secret(path: &std::path::Path) -> io::Result { + let raw = std::fs::read_to_string(path).map_err(|err| { + io::Error::new( + err.kind(), + format!( + "failed to read websocket auth secret {}: {err}", + path.display() + ), + ) + })?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + format!("websocket auth secret {} must not be empty", path.display()), + )); + } + Ok(trimmed.to_string()) +} + +fn absolute_path_arg(flag_name: &str, path: PathBuf) -> anyhow::Result { + AbsolutePathBuf::try_from(path).with_context(|| format!("{flag_name} must be an absolute path")) +} + +fn sha256_digest_arg(flag_name: &str, value: &str) -> anyhow::Result<[u8; 32]> { + let trimmed = value.trim(); + if trimmed.len() != 64 { + anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest"); + } + + let mut digest = [0u8; 32]; + for (index, pair) in trimmed.as_bytes().chunks_exact(2).enumerate() { + let high = hex_nibble(flag_name, pair[0])?; + let low = hex_nibble(flag_name, pair[1])?; + digest[index] = (high << 4) | low; + } + Ok(digest) +} + +fn hex_nibble(flag_name: &str, byte: u8) -> anyhow::Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest"), + } +} + +fn sha256_digest(input: &[u8]) -> [u8; 32] { + let mut digest = [0u8; 32]; + digest.copy_from_slice(&Sha256::digest(input)); + digest +} + +fn unauthorized(message: &'static str) -> WebsocketAuthError { + WebsocketAuthError { + status_code: StatusCode::UNAUTHORIZED, + message, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use hmac::Hmac; + use hmac::Mac; + use serde_json::json; + + type HmacSha256 = Hmac; + + fn signed_token(shared_secret: &[u8], claims: serde_json::Value) -> String { + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); + let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); + let payload = format!("{header}.{claims_segment}"); + let mut mac = HmacSha256::new_from_slice(shared_secret).unwrap(); + mac.update(payload.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + format!("{payload}.{signature}") + } + + #[test] + fn detects_unauthenticated_non_loopback_listener() { + let policy = WebsocketAuthPolicy::default(); + assert!(is_unauthenticated_non_loopback_listener( + "0.0.0.0:8765".parse().unwrap(), + &policy, + )); + assert!(!is_unauthenticated_non_loopback_listener( + "127.0.0.1:8765".parse().unwrap(), + &policy, + )); + assert!(!is_unauthenticated_non_loopback_listener( + "0.0.0.0:8765".parse().unwrap(), + &WebsocketAuthPolicy { + mode: Some(WebsocketAuthMode::CapabilityToken { + token_sha256: [0u8; 32], + }), + }, + )); + } + + #[test] + fn capability_token_args_require_token_file_or_hash() { + let err = AppServerWebsocketAuthArgs { + ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), + ..Default::default() + } + .try_into_settings() + .expect_err("capability-token mode should require a token source"); + assert!( + err.to_string().contains("--ws-token-file") + && err.to_string().contains("--ws-token-sha256"), + "unexpected error: {err}" + ); + } + + #[test] + fn capability_token_args_accept_token_hash() { + let settings = AppServerWebsocketAuthArgs { + ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), + ws_token_sha256: Some("ab".repeat(32)), + ..Default::default() + } + .try_into_settings() + .expect("capability-token hash args should parse"); + + assert_eq!( + settings, + AppServerWebsocketAuthSettings { + config: Some(AppServerWebsocketAuthConfig::CapabilityToken { + source: AppServerWebsocketCapabilityTokenSource::TokenSha256 { + token_sha256: [0xab; 32], + }, + }), + } + ); + } + + #[test] + fn capability_token_args_reject_multiple_token_sources() { + let err = AppServerWebsocketAuthArgs { + ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), + ws_token_file: Some(PathBuf::from("/tmp/token")), + ws_token_sha256: Some("ab".repeat(32)), + ..Default::default() + } + .try_into_settings() + .expect_err("capability-token mode should reject multiple token sources"); + assert!( + err.to_string().contains("mutually exclusive"), + "unexpected error: {err}" + ); + } + + #[test] + fn capability_token_args_reject_malformed_token_hash() { + let err = AppServerWebsocketAuthArgs { + ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), + ws_token_sha256: Some("not-a-sha256".to_string()), + ..Default::default() + } + .try_into_settings() + .expect_err("capability-token mode should reject malformed token hashes"); + assert!( + err.to_string().contains("64-character hex"), + "unexpected error: {err}" + ); + } + + #[test] + fn capability_token_hash_policy_authorizes_matching_bearer_token() { + let settings = AppServerWebsocketAuthSettings { + config: Some(AppServerWebsocketAuthConfig::CapabilityToken { + source: AppServerWebsocketCapabilityTokenSource::TokenSha256 { + token_sha256: sha256_digest(b"super-secret-token"), + }, + }), + }; + let policy = policy_from_settings(&settings).expect("hash policy should build"); + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_static("Bearer super-secret-token"), + ); + authorize_upgrade(&headers, &policy).expect("matching token should authorize"); + + headers.insert( + AUTHORIZATION, + HeaderValue::from_static("Bearer wrong-token"), + ); + let err = authorize_upgrade(&headers, &policy).expect_err("wrong token should fail"); + assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn signed_bearer_args_require_mode_when_mode_specific_flags_are_set() { + let err = AppServerWebsocketAuthArgs { + ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")), + ..Default::default() + } + .try_into_settings() + .expect_err("mode-specific flags should require --ws-auth"); + assert!( + err.to_string().contains("websocket auth flags require"), + "unexpected error: {err}" + ); + } + + #[test] + fn signed_bearer_args_default_clock_skew_and_trim_optional_claims() { + let settings = AppServerWebsocketAuthArgs { + ws_auth: Some(WebsocketAuthCliMode::SignedBearerToken), + ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")), + ws_issuer: Some(" issuer ".to_string()), + ws_audience: Some(" ".to_string()), + ..Default::default() + } + .try_into_settings() + .expect("signed bearer args should parse"); + + assert_eq!( + settings, + AppServerWebsocketAuthSettings { + config: Some(AppServerWebsocketAuthConfig::SignedBearerToken { + shared_secret_file: AbsolutePathBuf::from_absolute_path("/tmp/secret") + .expect("absolute path"), + issuer: Some("issuer".to_string()), + audience: None, + max_clock_skew_seconds: DEFAULT_MAX_CLOCK_SKEW_SECONDS, + }), + } + ); + } + + #[test] + fn signed_bearer_token_verification_rejects_tampering() { + let shared_secret = b"0123456789abcdef0123456789abcdef"; + let token = signed_token( + shared_secret, + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + }), + ); + let tampered = token.replace(".eyJleHAi", ".eyJleHBi"); + let err = verify_signed_bearer_token( + &tampered, + shared_secret, + /*issuer*/ None, + /*audience*/ None, + /*max_clock_skew_seconds*/ 30, + ) + .expect_err("tampered jwt should fail"); + assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn signed_bearer_token_verification_accepts_valid_token() { + let shared_secret = b"0123456789abcdef0123456789abcdef"; + let token = signed_token( + shared_secret, + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "issuer", + "aud": "audience", + }), + ); + verify_signed_bearer_token( + &token, + shared_secret, + Some("issuer"), + Some("audience"), + /*max_clock_skew_seconds*/ 30, + ) + .expect("valid signed token should verify"); + } + + #[test] + fn signed_bearer_token_verification_accepts_multiple_audiences() { + let shared_secret = b"0123456789abcdef0123456789abcdef"; + let token = signed_token( + shared_secret, + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "aud": ["other-audience", "audience"], + }), + ); + verify_signed_bearer_token( + &token, + shared_secret, + /*issuer*/ None, + Some("audience"), + /*max_clock_skew_seconds*/ 30, + ) + .expect("jwt audience arrays should verify"); + } + + #[test] + fn signed_bearer_token_verification_rejects_alg_none_tokens() { + let claims_segment = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + })) + .unwrap(), + ); + let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); + let token = format!("{header_segment}.{claims_segment}."); + let err = verify_signed_bearer_token( + &token, + b"0123456789abcdef0123456789abcdef", + /*issuer*/ None, + /*audience*/ None, + /*max_clock_skew_seconds*/ 30, + ) + .expect_err("alg=none jwt should be rejected"); + assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn signed_bearer_token_verification_rejects_missing_exp() { + let shared_secret = b"0123456789abcdef0123456789abcdef"; + let token = signed_token( + shared_secret, + json!({ + "iss": "issuer", + }), + ); + let err = verify_signed_bearer_token( + &token, + shared_secret, + /*issuer*/ None, + /*audience*/ None, + /*max_clock_skew_seconds*/ 30, + ) + .expect_err("jwt without exp should be rejected"); + assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn validate_signed_bearer_secret_rejects_short_secret() { + let err = validate_signed_bearer_secret(Path::new("/tmp/secret"), b"too-short") + .expect_err("short shared secret should be rejected"); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string().contains("must be at least 32 bytes"), + "unexpected error: {err}" + ); + } +} diff --git a/codex-rs/app-server-transport/src/transport/mod.rs b/codex-rs/app-server-transport/src/transport/mod.rs index dfd1d8fae7..3e76069f43 100644 --- a/codex-rs/app-server-transport/src/transport/mod.rs +++ b/codex-rs/app-server-transport/src/transport/mod.rs @@ -1,3 +1,5 @@ +pub mod auth; + use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingError; use crate::outgoing_message::OutgoingMessage; @@ -6,6 +8,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCMessage; use codex_core::config::find_codex_home; use codex_utils_absolute_path::AbsolutePathBuf; +use std::net::SocketAddr; use std::path::Path; use std::str::FromStr; use std::sync::atomic::AtomicU64; @@ -32,6 +35,7 @@ pub use remote_control::RemoteControlStartConfig; pub use remote_control::start_remote_control; pub use stdio::start_stdio_connection; pub use unix_socket::start_control_socket_acceptor; +pub use websocket::start_websocket_acceptor; const OVERLOADED_ERROR_CODE: i64 = -32001; @@ -50,6 +54,7 @@ pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result write!( f, - "unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, or `off`" + "unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, `ws://IP:PORT`, or `off`" ), AppServerTransportParseError::InvalidUnixSocketPath { listen_url, @@ -73,6 +79,10 @@ impl std::fmt::Display for AppServerTransportParseError { f, "invalid unix socket --listen URL `{listen_url}`; failed to resolve socket path: {message}" ), + AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!( + f, + "invalid websocket --listen URL `{listen_url}`; expected `ws://IP:PORT`" + ), } } } @@ -116,6 +126,13 @@ impl AppServerTransport { return Ok(Self::Off); } + if let Some(socket_addr) = listen_url.strip_prefix("ws://") { + let bind_address = socket_addr.parse::().map_err(|_| { + AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url.to_string()) + })?; + return Ok(Self::WebSocket { bind_address }); + } + Err(AppServerTransportParseError::UnsupportedListenUrl( listen_url.to_string(), )) @@ -151,7 +168,7 @@ pub enum TransportEvent { pub enum ConnectionOrigin { Stdio, InProcess, - UnixSocket, + WebSocket, RemoteControl, } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index a44c8522d9..60dfc3d845 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -1,6 +1,7 @@ use super::protocol::EnrollRemoteServerRequest; use super::protocol::EnrollRemoteServerResponse; use super::protocol::RemoteControlTarget; +use axum::http::HeaderMap; use codex_api::SharedAuthProvider; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; @@ -8,7 +9,6 @@ use codex_state::StateRuntime; use gethostname::gethostname; use std::io; use std::io::ErrorKind; -use tokio_tungstenite::tungstenite::http::HeaderMap; use tracing::info; use tracing::warn; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index c3111fbc79..472639bc68 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -19,6 +19,7 @@ use super::segment::ClientSegmentObservation; use super::segment::ClientSegmentReassembler; use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; use super::segment::split_server_envelope_for_transport; +use axum::http::HeaderValue; use base64::Engine; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlStatusChangedNotification; @@ -47,7 +48,6 @@ use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_util::sync::CancellationToken; use tracing::error; use tracing::info; diff --git a/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs b/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs index 4c77d84881..0b7dec0a23 100644 --- a/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs +++ b/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs @@ -52,14 +52,6 @@ fn listen_unix_socket_accepts_relative_custom_path() { ); } -#[test] -fn listen_websocket_url_is_not_supported() { - assert!(matches!( - AppServerTransport::from_listen_url("ws://127.0.0.1:0"), - Err(super::AppServerTransportParseError::UnsupportedListenUrl(_)) - )); -} - #[tokio::test] async fn control_socket_acceptor_upgrades_and_forwards_websocket_text_messages_and_pings() { let temp_dir = tempfile::TempDir::new().expect("temp dir"); diff --git a/codex-rs/app-server-transport/src/transport/websocket.rs b/codex-rs/app-server-transport/src/transport/websocket.rs index 086f76d7a8..177e378906 100644 --- a/codex-rs/app-server-transport/src/transport/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/websocket.rs @@ -1,17 +1,46 @@ use super::CHANNEL_CAPACITY; use super::ConnectionOrigin; use super::TransportEvent; +use super::auth::WebsocketAuthPolicy; +use super::auth::authorize_upgrade; +use super::auth::is_unauthenticated_non_loopback_listener; use super::forward_incoming_message; use super::next_connection_id; use super::serialize_outgoing_message; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::QueuedOutgoingMessage; +use axum::Router; +use axum::body::Body; +use axum::body::Bytes; +use axum::extract::ConnectInfo; +use axum::extract::State; +use axum::extract::ws::Message as AxumWebSocketMessage; +use axum::extract::ws::WebSocketUpgrade; +use axum::http::HeaderMap; +use axum::http::Request; +use axum::http::StatusCode; +use axum::http::header::ORIGIN; +use axum::middleware; +use axum::middleware::Next; +use axum::response::IntoResponse; +use axum::response::Response; +use axum::routing::any; +use axum::routing::get; use futures::SinkExt; use futures::StreamExt; +use owo_colors::OwoColorize; +use owo_colors::Stream; +use owo_colors::Style; +use std::io::Result as IoResult; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; use tokio::sync::mpsc; -use tokio_tungstenite::tungstenite::Bytes; +use tokio::task::JoinHandle; use tokio_tungstenite::tungstenite::Message as TungsteniteWebSocketMessage; use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::info; use tracing::warn; /// WebSocket clients can briefly lag behind normal turn output bursts while the @@ -19,6 +48,127 @@ use tracing::warn; const WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY: usize = 32 * 1024; const _: () = assert!(WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY > CHANNEL_CAPACITY); +fn colorize(text: &str, style: Style) -> String { + text.if_supports_color(Stream::Stderr, |value| value.style(style)) + .to_string() +} + +#[allow(clippy::print_stderr)] +fn print_websocket_startup_banner(addr: SocketAddr) { + let title = colorize("codex app-server (WebSockets)", Style::new().bold().cyan()); + let listening_label = colorize("listening on:", Style::new().dimmed()); + let listen_url = colorize(&format!("ws://{addr}"), Style::new().green()); + let ready_label = colorize("readyz:", Style::new().dimmed()); + let ready_url = colorize(&format!("http://{addr}/readyz"), Style::new().green()); + let health_label = colorize("healthz:", Style::new().dimmed()); + let health_url = colorize(&format!("http://{addr}/healthz"), Style::new().green()); + let note_label = colorize("note:", Style::new().dimmed()); + eprintln!("{title}"); + eprintln!(" {listening_label} {listen_url}"); + eprintln!(" {ready_label} {ready_url}"); + eprintln!(" {health_label} {health_url}"); + if addr.ip().is_loopback() { + eprintln!( + " {note_label} binds localhost only (use SSH port-forwarding for remote access)" + ); + } else { + eprintln!(" {note_label} websocket auth is required for non-localhost listeners"); + } +} + +#[derive(Clone)] +struct WebSocketListenerState { + transport_event_tx: mpsc::Sender, + auth_policy: Arc, +} + +async fn health_check_handler() -> StatusCode { + StatusCode::OK +} + +async fn reject_requests_with_origin_header( + request: Request, + next: Next, +) -> Result { + if request.headers().contains_key(ORIGIN) { + warn!( + method = %request.method(), + uri = %request.uri(), + "rejecting websocket listener request with Origin header" + ); + Err(StatusCode::FORBIDDEN) + } else { + Ok(next.run(request).await) + } +} + +async fn websocket_upgrade_handler( + websocket: WebSocketUpgrade, + ConnectInfo(peer_addr): ConnectInfo, + State(state): State, + headers: HeaderMap, +) -> impl IntoResponse { + if let Err(err) = authorize_upgrade(&headers, state.auth_policy.as_ref()) { + warn!( + %peer_addr, + message = err.message(), + "rejecting websocket client during upgrade" + ); + return (err.status_code(), err.message()).into_response(); + } + info!(%peer_addr, "websocket client connected"); + websocket + .on_upgrade(move |stream| async move { + let (websocket_writer, websocket_reader) = stream.split(); + run_websocket_connection(websocket_writer, websocket_reader, state.transport_event_tx) + .await; + }) + .into_response() +} + +pub async fn start_websocket_acceptor( + bind_address: SocketAddr, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, + auth_policy: WebsocketAuthPolicy, +) -> IoResult> { + if is_unauthenticated_non_loopback_listener(bind_address, &auth_policy) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "refusing to start non-loopback websocket listener {bind_address} without auth; configure `--ws-auth capability-token` or `--ws-auth signed-bearer-token`" + ), + )); + } + let listener = TcpListener::bind(bind_address).await?; + let local_addr = listener.local_addr()?; + print_websocket_startup_banner(local_addr); + info!("app-server websocket listening on ws://{local_addr}"); + + let router = Router::new() + .route("/readyz", get(health_check_handler)) + .route("/healthz", get(health_check_handler)) + .fallback(any(websocket_upgrade_handler)) + .layer(middleware::from_fn(reject_requests_with_origin_header)) + .with_state(WebSocketListenerState { + transport_event_tx, + auth_policy: Arc::new(auth_policy), + }); + let server = axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + shutdown_token.cancelled().await; + }); + Ok(tokio::spawn(async move { + if let Err(err) = server.await { + error!("websocket acceptor failed: {err}"); + } + info!("websocket acceptor shutting down"); + })) +} + pub(crate) async fn run_websocket_connection( websocket_writer: impl futures::sink::Sink + Send + 'static, websocket_reader: impl futures::stream::Stream> + Send + 'static, @@ -36,7 +186,7 @@ pub(crate) async fn run_websocket_connection( if transport_event_tx .send(TransportEvent::ConnectionOpened { connection_id, - origin: ConnectionOrigin::UnixSocket, + origin: ConnectionOrigin::WebSocket, writer: writer_tx, disconnect_sender: Some(disconnect_token.clone()), }) @@ -95,6 +245,26 @@ pub(crate) trait AppServerWebSocketMessage: Sized { fn into_incoming(self) -> Option; } +impl AppServerWebSocketMessage for AxumWebSocketMessage { + fn text(text: String) -> Self { + Self::Text(text.into()) + } + + fn pong(payload: Bytes) -> Self { + Self::Pong(payload) + } + + fn into_incoming(self) -> Option { + Some(match self { + Self::Text(text) => IncomingWebSocketMessage::Text(text.to_string()), + Self::Binary(_) => IncomingWebSocketMessage::Binary, + Self::Ping(payload) => IncomingWebSocketMessage::Ping(payload), + Self::Pong(_) => IncomingWebSocketMessage::Pong, + Self::Close(_) => IncomingWebSocketMessage::Close, + }) + } +} + impl AppServerWebSocketMessage for TungsteniteWebSocketMessage { fn text(text: String) -> Self { Self::Text(text.into()) diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index fcf731aa9b..ecc8d1c25a 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -28,6 +28,7 @@ axum = { workspace = true, default-features = false, features = [ "http1", "json", "tokio", + "ws", ] } codex-analytics = { workspace = true } codex-arg0 = { workspace = true } @@ -100,20 +101,22 @@ axum = { workspace = true, default-features = false, features = [ "tokio", ] } base64 = { workspace = true } -codex-uds = { workspace = true } codex-model-provider-info = { workspace = true } codex-utils-cargo-bin = { workspace = true } core_test_support = { workspace = true } flate2 = { workspace = true } +hmac = { workspace = true } opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true } pretty_assertions = { workspace = true } +reqwest = { workspace = true, features = ["rustls-tls"] } rmcp = { workspace = true, default-features = false, features = [ "elicitation", "server", "transport-streamable-http-server", ] } serial_test = { workspace = true } +sha2 = { workspace = true } shlex = { workspace = true } tar = { workspace = true } tokio-tungstenite = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c167627257..788fc9e7c3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -24,9 +24,18 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports Supported transports: - stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL) +- websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**) - unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake - off (`--listen off`): do not expose a local transport +When running with `--listen ws://IP:PORT`, the same listener also serves basic HTTP health probes: + +- `GET /readyz` returns `200 OK` once the listener is accepting new connections. +- `GET /healthz` returns `200 OK` when no `Origin` header is present. +- Any request carrying an `Origin` header is rejected with `403 Forbidden`. + +Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads. + The unix socket transport is intended for local app-server control-plane clients. `codex app-server proxy` opens exactly one raw stream connection to `$CODEX_HOME/app-server-control/app-server-control.sock` by default, or to `--sock PATH` when provided, and proxies bytes between that socket and stdin/stdout. @@ -210,7 +219,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread @@ -1537,6 +1546,8 @@ To enable or disable a skill by name: Use `hooks/list` to fetch discovered hooks for one or more `cwds`. Each result is evaluated with that `cwd`'s effective config, so feature gates and discovered config layers can differ within a single response. +For linked Git worktrees, project hook declarations come from the matching `.codex/` folders in the root checkout rather than from divergent hook declarations stored only in the linked worktree. This keeps each repo on one authoritative project-hook definition and one trust state. + Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading. For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional. diff --git a/codex-rs/app-server/src/app_server_tracing.rs b/codex-rs/app-server/src/app_server_tracing.rs index 21acf1c86f..6e8133740f 100644 --- a/codex-rs/app-server/src/app_server_tracing.rs +++ b/codex-rs/app-server/src/app_server_tracing.rs @@ -1,7 +1,7 @@ //! Tracing helpers shared by socket and in-process app-server entry points. //! //! The in-process path intentionally reuses the same span shape as JSON-RPC -//! transports so request telemetry stays comparable across stdio, unix socket, +//! transports so request telemetry stays comparable across stdio, websocket, //! and embedded callers. [`typed_request_span`] is the in-process counterpart //! of [`request_span`] and stamps `rpc.transport` as `"in-process"` while //! deriving client identity from the typed [`ClientRequest`] rather than @@ -86,6 +86,7 @@ fn transport_name(transport: &AppServerTransport) -> &'static str { match transport { AppServerTransport::Stdio => "stdio", AppServerTransport::UnixSocket { .. } => "unix_socket", + AppServerTransport::WebSocket { .. } => "websocket", AppServerTransport::Off => "off", } } diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 030829fa4b..628a4cc8ea 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -30,6 +30,7 @@ pub(crate) struct ConfigManager { cli_overrides: Arc>>, runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, + strict_config: bool, cloud_requirements: Arc>, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc>>, @@ -40,6 +41,7 @@ impl ConfigManager { codex_home: PathBuf, cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, + strict_config: bool, cloud_requirements: CloudRequirementsLoader, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc, @@ -49,6 +51,7 @@ impl ConfigManager { cli_overrides: Arc::new(RwLock::new(cli_overrides)), runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())), loader_overrides, + strict_config, cloud_requirements: Arc::new(RwLock::new(cloud_requirements)), arg0_paths, thread_config_loader: Arc::new(RwLock::new(thread_config_loader)), @@ -217,6 +220,7 @@ impl ConfigManager { .codex_home(self.codex_home.clone()) .cli_overrides(merged_cli_overrides) .loader_overrides(self.loader_overrides.clone()) + .strict_config(self.strict_config) .harness_overrides(typesafe_overrides) .fallback_cwd(fallback_cwd) .cloud_requirements(self.current_cloud_requirements()) @@ -245,7 +249,10 @@ impl ConfigManager { &self.codex_home, cwd, &self.current_cli_overrides(), - self.loader_overrides.clone(), + codex_config::ConfigLoadOptions { + loader_overrides: self.loader_overrides.clone(), + strict_config: self.strict_config, + }, self.current_cloud_requirements(), thread_config_loader.as_ref(), ) @@ -280,6 +287,7 @@ impl ConfigManager { codex_home, cli_overrides, loader_overrides, + /*strict_config*/ false, cloud_requirements, Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index d812888e62..c75c2d5ad1 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -119,6 +119,8 @@ pub struct InProcessStartArgs { pub cli_overrides: Vec<(String, TomlValue)>, /// Loader override knobs used by config API paths. pub loader_overrides: LoaderOverrides, + /// Whether config API paths should reject unknown config fields. + pub strict_config: bool, /// Preloaded cloud requirements provider. pub cloud_requirements: CloudRequirementsLoader, /// Loader used to fetch typed thread config sources before a thread starts. @@ -409,6 +411,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult Arc IoResult<()> { - run_main_with_transport( + run_main_with_transport_options( arg0_paths, cli_config_overrides, loader_overrides, + strict_config, default_analytics_enabled, AppServerTransport::Stdio, SessionSource::VSCode, + AppServerWebsocketAuthSettings::default(), + AppServerRuntimeOptions::default(), ) .await } @@ -402,34 +411,16 @@ impl Default for AppServerRuntimeOptions { } } -pub async fn run_main_with_transport( - arg0_paths: Arg0DispatchPaths, - cli_config_overrides: CliConfigOverrides, - loader_overrides: LoaderOverrides, - default_analytics_enabled: bool, - transport: AppServerTransport, - session_source: SessionSource, -) -> IoResult<()> { - run_main_with_transport_options( - arg0_paths, - cli_config_overrides, - loader_overrides, - default_analytics_enabled, - transport, - session_source, - AppServerRuntimeOptions::default(), - ) - .await -} - #[allow(clippy::too_many_arguments)] pub async fn run_main_with_transport_options( arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, loader_overrides: LoaderOverrides, + strict_config: bool, default_analytics_enabled: bool, transport: AppServerTransport, session_source: SessionSource, + auth: AppServerWebsocketAuthSettings, runtime_options: AppServerRuntimeOptions, ) -> IoResult<()> { let (transport_event_tx, mut transport_event_rx) = @@ -462,6 +453,7 @@ pub async fn run_main_with_transport_options( codex_home.to_path_buf(), cli_kv_overrides.clone(), loader_overrides, + strict_config, Default::default(), arg0_paths.clone(), Arc::new(NoopThreadConfigLoader), @@ -490,6 +482,10 @@ pub async fn run_main_with_transport_options( { Ok(config) => (config, true), Err(err) => { + if strict_config { + return Err(err); + } + let message = config_warning_from_error("Invalid configuration; using defaults.", &err); config_warnings.push(message); ( @@ -669,6 +665,16 @@ pub async fn run_main_with_transport_options( .await?; transport_accept_handles.push(accept_handle); } + AppServerTransport::WebSocket { bind_address } => { + let accept_handle = start_websocket_acceptor( + *bind_address, + transport_event_tx.clone(), + transport_shutdown_token.clone(), + policy_from_settings(&auth)?, + ) + .await?; + transport_accept_handles.push(accept_handle); + } AppServerTransport::Off => {} } @@ -740,7 +746,7 @@ pub async fn run_main_with_transport_options( } OutboundControlEvent::DisconnectAll => { info!( - "disconnecting {} outbound connection(s) for graceful restart", + "disconnecting {} outbound websocket connection(s) for graceful restart", outbound_connections.len() ); for connection_state in outbound_connections.values() { @@ -1068,9 +1074,9 @@ pub async fn run_main_with_transport_options( fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransport { match transport { AppServerTransport::Stdio => AppServerRpcTransport::Stdio, - AppServerTransport::UnixSocket { .. } | AppServerTransport::Off => { - AppServerRpcTransport::Websocket - } + AppServerTransport::UnixSocket { .. } + | AppServerTransport::WebSocket { .. } + | AppServerTransport::Off => AppServerRpcTransport::Websocket, } } diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 6e10bf7f80..af30db8cf0 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -1,6 +1,7 @@ use clap::Parser; use codex_app_server::AppServerRuntimeOptions; use codex_app_server::AppServerTransport; +use codex_app_server::AppServerWebsocketAuthArgs; use codex_app_server::PluginStartupTasks; use codex_app_server::run_main_with_transport_options; use codex_arg0::Arg0DispatchPaths; @@ -18,7 +19,7 @@ const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_C #[derive(Debug, Parser)] struct AppServerArgs { /// Transport endpoint URL. Supported values: `stdio://` (default), - /// `unix://`, `unix://PATH`, `off`. + /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. #[arg( long = "listen", value_name = "URL", @@ -35,6 +36,13 @@ struct AppServerArgs { )] session_source: SessionSource, + #[command(flatten)] + auth: AppServerWebsocketAuthArgs, + + /// Fail if config.toml contains unknown configuration fields. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, + /// Hidden debug-only test hook used by integration tests that spawn the /// production app-server binary. #[cfg(debug_assertions)] @@ -44,7 +52,14 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { - let args = AppServerArgs::parse(); + let AppServerArgs { + listen, + session_source, + auth, + strict_config, + #[cfg(debug_assertions)] + disable_plugin_startup_tasks_for_tests, + } = AppServerArgs::parse(); let loader_overrides = if disable_managed_config_from_debug_env() { LoaderOverrides::without_managed_config_for_tests() } else { @@ -52,11 +67,11 @@ fn main() -> anyhow::Result<()> { .map(LoaderOverrides::with_managed_config_path_for_tests) .unwrap_or_default() }; - let transport = args.listen; - let session_source = args.session_source; + let transport = listen; + let auth = auth.try_into_settings()?; let mut runtime_options = AppServerRuntimeOptions::default(); #[cfg(debug_assertions)] - if args.disable_plugin_startup_tasks_for_tests { + if disable_plugin_startup_tasks_for_tests { runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip; } @@ -64,9 +79,11 @@ fn main() -> anyhow::Result<()> { arg0_paths, CliConfigOverrides::default(), loader_overrides, + strict_config, /*default_analytics_enabled*/ false, transport, session_source, + auth, runtime_options, ) .await?; diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 31a6f4afd8..f7d32b2ea8 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -207,6 +207,7 @@ mod tests { temp_dir.path().to_path_buf(), Vec::new(), LoaderOverrides::without_managed_config_for_tests(), + /*strict_config*/ false, CloudRequirementsLoader::default(), Arg0DispatchPaths::default(), loader.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 867cd64c99..a4eb9b6c9d 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1103,6 +1103,9 @@ impl MessageProcessor { ClientRequest::PluginShareList { params, .. } => { self.plugin_processor.plugin_share_list(params).await } + ClientRequest::PluginShareCheckout { params, .. } => { + self.plugin_processor.plugin_share_checkout(params).await + } ClientRequest::PluginShareDelete { params, .. } => { self.plugin_processor.plugin_share_delete(params).await } 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 516e042301..c955d06ba2 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -239,6 +239,7 @@ async fn build_test_processor( config.codex_home.to_path_buf(), Vec::new(), LoaderOverrides::default(), + /*strict_config*/ false, CloudRequirementsLoader::default(), Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index f24dcaa34f..cf68f638ba 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -115,6 +115,8 @@ use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginShareCheckoutParams; +use codex_app_server_protocol::PluginShareCheckoutResponse; use codex_app_server_protocol::PluginShareContext; use codex_app_server_protocol::PluginShareDeleteParams; use codex_app_server_protocol::PluginShareDeleteResponse; @@ -401,7 +403,6 @@ use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch; use codex_thread_store::ThreadSortKey as StoreThreadSortKey; use codex_thread_store::ThreadStore; use codex_thread_store::ThreadStoreError; -use codex_thread_store::UpdateThreadMetadataParams as StoreUpdateThreadMetadataParams; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use std::collections::HashMap; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index c73d6700e7..f32f87ed15 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -2,6 +2,8 @@ use super::*; // Duration before a browser ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); +// The override is intentionally available only in debug builds, matching the login path below. +#[cfg(debug_assertions)] const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; enum ActiveLogin { diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 89082492c1..91fabf7616 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -525,6 +525,7 @@ impl CatalogRequestProcessor { }; let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig { feature_enabled: config.features.enabled(Feature::CodexHooks), + bypass_hook_trust: config.bypass_hook_trust, config_layer_stack: Some(config.config_layer_stack), plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(), plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(), diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 5365030d6b..1533728e9f 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -449,6 +449,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR } normalized }), + allow_managed_hooks_only: requirements.allow_managed_hooks_only, feature_requirements: requirements .feature_requirements .map(|requirements| requirements.entries), @@ -631,3 +632,21 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> })); error } + +#[cfg(test)] +mod tests { + use super::map_requirements_toml_to_api; + use codex_config::ConfigRequirementsToml; + use pretty_assertions::assert_eq; + + #[test] + fn requirements_api_includes_allow_managed_hooks_only() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + allow_managed_hooks_only: Some(true), + ..ConfigRequirementsToml::default() + }); + + assert_eq!(mapped.allow_managed_hooks_only, Some(true)); + assert_eq!(mapped.hooks, None); + } +} diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 1c741944b5..21b7da679b 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -323,7 +323,7 @@ impl ExternalAgentConfigRequestProcessor { .thread .update_thread_metadata( ThreadMetadataPatch { - name: Some(name), + name: Some(Some(name)), ..Default::default() }, /*include_archived*/ false, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 125accad82..6980945ae2 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -111,6 +111,7 @@ fn share_context_for_source( .cloned() .map(|remote_plugin_id| PluginShareContext { remote_plugin_id, + remote_version: None, discoverability: None, share_url: None, creator_account_user_id: None, @@ -312,6 +313,15 @@ impl PluginRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn plugin_share_checkout( + &self, + params: PluginShareCheckoutParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_checkout_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn plugin_share_delete( &self, params: PluginShareDeleteParams, @@ -483,6 +493,7 @@ impl PluginRequestProcessor { PluginSummary { id: plugin.id, remote_plugin_id: None, + local_version: plugin.local_version, installed: plugin.installed, enabled: plugin.enabled, name: plugin.name, @@ -533,7 +544,9 @@ impl PluginRequestProcessor { if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) { remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory); } - if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) { + if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) + && config.features.enabled(Feature::PluginSharing) + { remote_sources.push(RemoteMarketplaceSource::SharedWithMe); } if !remote_sources.is_empty() { @@ -673,17 +686,21 @@ impl PluginRequestProcessor { ) .await { - Ok(Some(remote_share_context)) - if remote_share_context.share_principals.is_some() => - { - Some(remote_plugin_share_context_to_info(remote_share_context)) - } - Ok(Some(_)) => { - warn!( - remote_plugin_id = %context.remote_plugin_id, - "remote shared plugin detail did not include share principals; returning local share mapping context" - ); - Some(context) + Ok(Some(remote_share_context)) => { + if remote_share_context.share_principals.is_some() { + Some(remote_plugin_share_context_to_info(remote_share_context)) + } else { + let remote_version = remote_share_context.remote_version; + let remote_plugin_id = context.remote_plugin_id.clone(); + warn!( + remote_plugin_id = %remote_plugin_id, + "remote shared plugin detail did not include share principals; returning local share mapping context with remote version" + ); + Some(PluginShareContext { + remote_version, + ..context + }) + } } Ok(None) => { warn!( @@ -725,6 +742,7 @@ impl PluginRequestProcessor { summary: PluginSummary { id: outcome.plugin.id, remote_plugin_id: None, + local_version: outcome.plugin.local_version, name: outcome.plugin.name, share_context, source: marketplace_plugin_source_to_info(outcome.plugin.source), @@ -840,6 +858,9 @@ impl PluginRequestProcessor { params: PluginShareSaveParams, ) -> Result { let (config, auth) = self.load_plugin_share_config_and_auth().await?; + if !config.features.enabled(Feature::PluginSharing) { + return Err(invalid_request("plugin sharing is disabled")); + } let PluginShareSaveParams { plugin_path, remote_plugin_id, @@ -961,6 +982,42 @@ impl PluginRequestProcessor { Ok(PluginShareListResponse { data }) } + async fn plugin_share_checkout_response( + &self, + params: PluginShareCheckoutParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + if !config.features.enabled(Feature::PluginSharing) { + return Err(invalid_request("plugin sharing is disabled")); + } + let PluginShareCheckoutParams { remote_plugin_id } = params; + if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) { + return Err(invalid_request("invalid remote plugin id")); + } + + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let result = codex_core_plugins::remote::checkout_remote_plugin_share( + &remote_plugin_service_config, + auth.as_ref(), + config.codex_home.as_path(), + &remote_plugin_id, + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "checkout plugin share"))?; + self.clear_plugin_related_caches(); + Ok(PluginShareCheckoutResponse { + remote_plugin_id: result.remote_plugin_id, + plugin_id: result.plugin_id, + plugin_name: result.plugin_name, + plugin_path: result.plugin_path, + marketplace_name: result.marketplace_name, + marketplace_path: result.marketplace_path, + remote_version: result.remote_version, + }) + } + async fn plugin_share_delete_response( &self, params: PluginShareDeleteParams, @@ -1589,6 +1646,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS PluginSummary { id: summary.id, remote_plugin_id: Some(summary.remote_plugin_id), + local_version: None, name: summary.name, share_context: summary .share_context @@ -1609,6 +1667,7 @@ fn remote_plugin_share_context_to_info( ) -> PluginShareContext { PluginShareContext { remote_plugin_id: context.remote_plugin_id, + remote_version: context.remote_version, discoverability: Some(remote_plugin_share_discoverability_to_info( context.discoverability, )), @@ -1680,6 +1739,7 @@ fn remote_plugin_catalog_error_to_jsonrpc( invalid_request(message) } RemotePluginCatalogError::InvalidPluginPath { .. } + | RemotePluginCatalogError::PluginShareCheckoutNotAvailable { .. } | RemotePluginCatalogError::ArchiveTooLarge { .. } | RemotePluginCatalogError::UnknownMarketplace { .. } => invalid_request(message), RemotePluginCatalogError::AuthToken(_) 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 9b5cef069d..46fb8ed80d 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1430,17 +1430,17 @@ impl ThreadRequestProcessor { }; let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; - self.thread_store - .update_thread_metadata(StoreUpdateThreadMetadataParams { + self.thread_manager + .update_thread_metadata( thread_id, - patch: StoreThreadMetadataPatch { - name: Some(name.clone()), + StoreThreadMetadataPatch { + name: Some(Some(name.clone())), ..Default::default() }, - include_archived: false, - }) + /*include_archived*/ false, + ) .await - .map_err(|err| thread_store_write_error("set thread name", err))?; + .map_err(|err| core_thread_write_error("set thread name", err))?; Ok(( ThreadSetNameResponse {}, @@ -1459,33 +1459,17 @@ impl ThreadRequestProcessor { let thread_id = ThreadId::from_string(&thread_id) .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; - if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { - if thread.config_snapshot().await.ephemeral { - return Err(invalid_request(format!( - "ephemeral thread does not support memory mode updates: {thread_id}" - ))); - } - - thread - .set_thread_memory_mode(mode.to_core()) - .await - .map_err(|err| { - internal_error(format!("failed to set thread memory mode: {err}")) - })?; - return Ok(ThreadMemoryModeSetResponse {}); - } - - self.thread_store - .update_thread_metadata(StoreUpdateThreadMetadataParams { + self.thread_manager + .update_thread_metadata( thread_id, - patch: StoreThreadMetadataPatch { + StoreThreadMetadataPatch { memory_mode: Some(mode.to_core()), ..Default::default() }, - include_archived: false, - }) + /*include_archived*/ false, + ) .await - .map_err(|err| thread_store_write_error("set thread memory mode", err))?; + .map_err(|err| core_thread_write_error("set thread memory mode", err))?; Ok(ThreadMemoryModeSetResponse {}) } @@ -1551,35 +1535,19 @@ impl ThreadRequestProcessor { ..Default::default() }; - let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); let updated_thread = { let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; - if let Some(loaded_thread) = loaded_thread.as_ref() { - if loaded_thread.config_snapshot().await.ephemeral { - return Err(invalid_request(format!( - "ephemeral thread does not support metadata updates: {thread_id}" - ))); - } - loaded_thread - .update_thread_metadata(patch, /*include_archived*/ true) - .await - } else { - self.thread_store - .update_thread_metadata(StoreUpdateThreadMetadataParams { - thread_id: thread_uuid, - patch, - include_archived: true, - }) - .await - } - .map_err(|err| thread_store_write_error("update thread metadata", err))? + self.thread_manager + .update_thread_metadata(thread_uuid, patch, /*include_archived*/ true) + .await + .map_err(|err| core_thread_write_error("update thread metadata", err))? }; let (mut thread, _) = thread_from_stored_thread( updated_thread, self.config.model_provider_id.as_str(), &self.config.cwd, ); - if let Some(loaded_thread) = loaded_thread.as_ref() { + if let Ok(loaded_thread) = self.thread_manager.get_thread(thread_uuid).await { thread.session_id = loaded_thread.session_configured().session_id.to_string(); } self.attach_thread_name(thread_uuid, &mut thread).await; @@ -3707,15 +3675,13 @@ fn conversation_summary_rollout_path_read_error( } } -fn thread_store_write_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError { +fn core_thread_write_error(operation: &str, err: CodexErr) -> JSONRPCErrorError { match err { - ThreadStoreError::ThreadNotFound { thread_id } => { + CodexErr::ThreadNotFound(thread_id) => { invalid_request(format!("thread not found: {thread_id}")) } - ThreadStoreError::InvalidRequest { message } => invalid_request(message), - ThreadStoreError::Unsupported { operation } => { - unsupported_thread_store_operation(operation) - } + CodexErr::InvalidRequest(message) => invalid_request(message), + CodexErr::UnsupportedOperation(message) => method_not_found(message), err => internal_error(format!("failed to {operation}: {err}")), } } diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 61a31f9c4d..8bda80d6cf 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -582,6 +582,7 @@ mod thread_processor_behavior_tests { temp_dir.path().to_path_buf(), Vec::new(), LoaderOverrides::default(), + /*strict_config*/ false, CloudRequirementsLoader::default(), Arg0DispatchPaths::default(), Arc::new(StaticThreadConfigLoader::new(vec![ diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index adf6b1016e..4eae17e469 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -22,9 +22,11 @@ pub(crate) use codex_app_server_transport::RemoteControlHandle; pub(crate) use codex_app_server_transport::RemoteControlStartConfig; pub(crate) use codex_app_server_transport::TransportEvent; pub use codex_app_server_transport::app_server_control_socket_path; +pub use codex_app_server_transport::auth; pub(crate) use codex_app_server_transport::start_control_socket_acceptor; pub(crate) use codex_app_server_transport::start_remote_control; pub(crate) use codex_app_server_transport::start_stdio_connection; +pub(crate) use codex_app_server_transport::start_websocket_acceptor; pub(crate) struct ConnectionState { pub(crate) outbound_initialized: Arc, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 754d1f9467..5253850107 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -149,6 +149,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/app-server/tests/suite/mod.rs b/codex-rs/app-server/tests/suite/mod.rs index c60f4a32a1..bac309a930 100644 --- a/codex-rs/app-server/tests/suite/mod.rs +++ b/codex-rs/app-server/tests/suite/mod.rs @@ -1,4 +1,5 @@ mod auth; mod conversation_summary; mod fuzzy_file_search; +mod strict_config; mod v2; diff --git a/codex-rs/app-server/tests/suite/strict_config.rs b/codex-rs/app-server/tests/suite/strict_config.rs new file mode 100644 index 0000000000..93784c9752 --- /dev/null +++ b/codex-rs/app-server/tests/suite/strict_config.rs @@ -0,0 +1,33 @@ +use std::process::Command; + +use anyhow::Result; +use tempfile::TempDir; + +#[test] +fn strict_config_rejects_unknown_config_fields_for_standalone_app_server() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +foo = "bar" +"#, + )?; + + let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex-app-server")?) + .env("CODEX_HOME", codex_home.path()) + .env( + "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", + codex_home.path().join("managed_config.toml"), + ) + .args(["--strict-config", "--listen", "off"]) + .output()?; + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr)?; + assert!( + stderr.contains("unknown configuration field `foo`"), + "expected strict config error in stderr, got: {stderr}" + ); + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index f65503aa9c..211cec9355 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -861,10 +861,10 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate .as_nanos() ); - let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?; + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; - let mut ws1 = connect_websocket(&socket_path).await?; - let mut ws2 = connect_websocket(&socket_path).await?; + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; send_initialize_request(&mut ws1, /*id*/ 1, "ws_client_one").await?; read_initialize_response(&mut ws1, /*request_id*/ 1).await?; @@ -929,7 +929,7 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate process .kill() .await - .context("failed to stop app-server process")?; + .context("failed to stop websocket app-server process")?; Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index ac322cbdc2..cf76b9d573 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -4,6 +4,8 @@ use anyhow::bail; use app_test_support::DISABLE_PLUGIN_STARTUP_TASKS_ARG; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCError; @@ -16,14 +18,18 @@ use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; -use codex_uds::UnixStream; use futures::SinkExt; use futures::StreamExt; +use hmac::Hmac; +use hmac::Mac; +use reqwest::StatusCode; use serde_json::json; +use sha2::Sha256; +use std::net::SocketAddr; use std::path::Path; -use std::path::PathBuf; use std::process::Stdio; use tempfile::TempDir; +use time::OffsetDateTime; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio::process::Child; @@ -32,29 +38,37 @@ use tokio::time::Duration; use tokio::time::Instant; use tokio::time::sleep; use tokio::time::timeout; +use tokio_tungstenite::MaybeTlsStream; use tokio_tungstenite::WebSocketStream; -use tokio_tungstenite::client_async; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::tungstenite::Message as WebSocketMessage; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; +use tokio_tungstenite::tungstenite::http::header::ORIGIN; // macOS and Windows CI can spend tens of seconds starting the app-server test -// binary under Bazel before it accepts JSON-RPC over the control socket. +// binary under Bazel before it accepts JSON-RPC or reports its websocket bind +// address. #[cfg(any(target_os = "macos", windows))] pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); #[cfg(not(any(target_os = "macos", windows)))] pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); -pub(super) type WsClient = WebSocketStream; +pub(super) type WsClient = WebSocketStream>; +type HmacSha256 = Hmac; #[tokio::test] -async fn unix_socket_transport_routes_per_connection_handshake_and_responses() -> Result<()> { +async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?; + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; - let mut ws1 = connect_websocket(&socket_path).await?; - let mut ws2 = connect_websocket(&socket_path).await?; + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; send_initialize_request(&mut ws1, /*id*/ 1, "ws_client_one").await?; let first_init = read_response_for_id(&mut ws1, /*id*/ 1).await?; @@ -86,20 +100,259 @@ async fn unix_socket_transport_routes_per_connection_handshake_and_responses() - process .kill() .await - .context("failed to stop app-server process")?; + .context("failed to stop websocket app-server process")?; Ok(()) } #[tokio::test] -async fn unix_socket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_timeout() --> Result<()> { +async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?; + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + let client = reqwest::Client::new(); - let mut ws1 = connect_websocket(&socket_path).await?; + let readyz = http_get(&client, bind_addr, "/readyz").await?; + assert_eq!(readyz.status(), StatusCode::OK); + + let healthz = http_get(&client, bind_addr, "/healthz").await?; + assert_eq!(healthz.status(), StatusCode::OK); + + let mut ws = connect_websocket(bind_addr).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_health_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_rejects_browser_origin_without_auth() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + + let mut ws = connect_websocket(bind_addr).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_loopback_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + drop(ws); + + assert_websocket_connect_rejected_with_headers( + bind_addr, + /*bearer_token*/ None, + Some("https://evil.example"), + StatusCode::FORBIDDEN, + ) + .await?; + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_rejects_missing_and_invalid_capability_tokens() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let token_file = codex_home.path().join("app-server-token"); + std::fs::write(&token_file, "super-secret-token\n")?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let auth_args = vec![ + "--ws-auth".to_string(), + "capability-token".to_string(), + "--ws-token-file".to_string(), + token_file.display().to_string(), + ]; + + let (mut process, bind_addr) = + spawn_websocket_server_with_args(codex_home.path(), "ws://0.0.0.0:0", &auth_args).await?; + + assert_websocket_connect_rejected(bind_addr, /*bearer_token*/ None).await?; + assert_websocket_connect_rejected(bind_addr, Some("wrong-token")).await?; + + let mut ws = connect_websocket_with_bearer(bind_addr, Some("super-secret-token")).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_auth_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_verifies_signed_short_lived_bearer_tokens() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let shared_secret_file = codex_home.path().join("app-server-signing-secret"); + let shared_secret = "0123456789abcdef0123456789abcdef"; + std::fs::write(&shared_secret_file, format!("{shared_secret}\n"))?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let auth_args = vec![ + "--ws-auth".to_string(), + "signed-bearer-token".to_string(), + "--ws-shared-secret-file".to_string(), + shared_secret_file.display().to_string(), + "--ws-issuer".to_string(), + "codex-enroller".to_string(), + "--ws-audience".to_string(), + "codex-app-server".to_string(), + "--ws-max-clock-skew-seconds".to_string(), + "1".to_string(), + ]; + + let (mut process, bind_addr) = + spawn_websocket_server_with_args(codex_home.path(), "ws://127.0.0.1:0", &auth_args).await?; + let expired_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() - 30, + "iss": "codex-enroller", + "aud": "codex-app-server", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(expired_token.as_str())).await?; + + let malformed_token = "not-a-jwt"; + assert_websocket_connect_rejected(bind_addr, Some(malformed_token)).await?; + + let not_yet_valid_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "nbf": OffsetDateTime::now_utc().unix_timestamp() + 30, + "iss": "codex-enroller", + "aud": "codex-app-server", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(not_yet_valid_token.as_str())).await?; + + let wrong_issuer_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "someone-else", + "aud": "codex-app-server", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(wrong_issuer_token.as_str())).await?; + + let wrong_audience_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "codex-enroller", + "aud": "wrong-audience", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(wrong_audience_token.as_str())).await?; + + let wrong_signature_token = signed_bearer_token( + b"fedcba9876543210fedcba9876543210", + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "codex-enroller", + "aud": "codex-app-server", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(wrong_signature_token.as_str())).await?; + + let valid_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "codex-enroller", + "aud": "codex-app-server", + }), + )?; + let mut ws = connect_websocket_with_bearer(bind_addr, Some(valid_token.as_str())).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_signed_auth_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_rejects_short_signed_bearer_secret_configuration() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let shared_secret_file = codex_home.path().join("app-server-signing-secret"); + std::fs::write(&shared_secret_file, "too-short\n")?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let output = run_websocket_server_to_completion_with_args( + codex_home.path(), + "ws://127.0.0.1:0", + &[ + "--ws-auth".to_string(), + "signed-bearer-token".to_string(), + "--ws-shared-secret-file".to_string(), + shared_secret_file.display().to_string(), + ], + ) + .await?; + assert!( + !output.status.success(), + "short shared secret should fail websocket server startup" + ); + let stderr = String::from_utf8(output.stderr).context("stderr should be valid utf-8")?; + assert!( + stderr.contains("must be at least 32 bytes"), + "unexpected stderr: {stderr}" + ); + + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_rejects_unauthenticated_non_loopback_startup() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let output = + run_websocket_server_to_completion_with_args(codex_home.path(), "ws://0.0.0.0:0", &[]) + .await?; + assert!( + !output.status.success(), + "unauthenticated non-loopback listener should fail websocket server startup" + ); + let stderr = String::from_utf8(output.stderr).context("stderr should be valid utf-8")?; + assert!( + stderr.contains("refusing to start non-loopback websocket listener"), + "unexpected stderr: {stderr}" + ); + + Ok(()) +} + +#[tokio::test] +async fn websocket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_timeout() -> Result<()> +{ + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + + let mut ws1 = connect_websocket(bind_addr).await?; send_initialize_request(&mut ws1, /*id*/ 1, "ws_thread_owner").await?; read_response_for_id(&mut ws1, /*id*/ 1).await?; @@ -109,7 +362,7 @@ async fn unix_socket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_t ws1.close(None).await.context("failed to close websocket")?; drop(ws1); - let mut ws2 = connect_websocket(&socket_path).await?; + let mut ws2 = connect_websocket(bind_addr).await?; send_initialize_request(&mut ws2, /*id*/ 4, "ws_reconnect_client").await?; read_response_for_id(&mut ws2, /*id*/ 4).await?; @@ -118,29 +371,26 @@ async fn unix_socket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_t process .kill() .await - .context("failed to stop app-server process")?; + .context("failed to stop websocket app-server process")?; Ok(()) } -pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child, PathBuf, TempDir)> { +pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child, SocketAddr)> { + spawn_websocket_server_with_args(codex_home, "ws://127.0.0.1:0", &[]).await +} + +pub(super) async fn spawn_websocket_server_with_args( + codex_home: &Path, + listen_url: &str, + extra_args: &[String], +) -> Result<(Child, SocketAddr)> { let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") .context("should find app-server binary")?; - #[cfg(unix)] - let socket_dir = tempfile::Builder::new() - .prefix("cxs-") - .tempdir_in("/tmp") - .context("failed to create short app-server socket temp dir")?; - #[cfg(not(unix))] - let socket_dir = tempfile::Builder::new() - .prefix("cxs-") - .tempdir() - .context("failed to create app-server socket temp dir")?; - let socket_path = socket_dir.path().join("c.sock"); - let listen_url = format!("unix://{}", socket_path.display()); let mut cmd = Command::new(program); cmd.arg("--listen") - .arg(&listen_url) + .arg(listen_url) .arg(DISABLE_PLUGIN_STARTUP_TASKS_ARG) + .args(extra_args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -149,62 +399,194 @@ pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child, let mut process = cmd .kill_on_drop(true) .spawn() - .context("failed to spawn app-server process")?; + .context("failed to spawn websocket app-server process")?; let stderr = process .stderr .take() - .context("failed to capture app-server stderr")?; + .context("failed to capture websocket app-server stderr")?; let mut stderr_reader = BufReader::new(stderr).lines(); + let deadline = Instant::now() + DEFAULT_READ_TIMEOUT; + let bind_addr = loop { + let line = timeout( + deadline.saturating_duration_since(Instant::now()), + stderr_reader.next_line(), + ) + .await + .context("timed out waiting for websocket app-server to report bound websocket address")? + .context("failed to read websocket app-server stderr")? + .context("websocket app-server exited before reporting bound websocket address")?; + eprintln!("[websocket app-server stderr] {line}"); + + let stripped_line = { + let mut stripped = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\u{1b}' && matches!(chars.peek(), Some(&'[')) { + chars.next(); + for next in chars.by_ref() { + if ('@'..='~').contains(&next) { + break; + } + } + continue; + } + stripped.push(ch); + } + stripped + }; + + if let Some(bind_addr) = stripped_line + .split_whitespace() + .find_map(|token| token.strip_prefix("ws://")) + .and_then(|addr| addr.parse::().ok()) + { + break bind_addr; + } + }; + tokio::spawn(async move { while let Ok(Some(line)) = stderr_reader.next_line().await { - eprintln!("[app-server stderr] {line}"); + eprintln!("[websocket app-server stderr] {line}"); } }); + Ok((process, bind_addr)) +} + +pub(super) async fn connect_websocket(bind_addr: SocketAddr) -> Result { + connect_websocket_with_bearer(bind_addr, /*bearer_token*/ None).await +} + +pub(super) async fn connect_websocket_with_bearer( + bind_addr: SocketAddr, + bearer_token: Option<&str>, +) -> Result { + let url = format!("ws://{}", connectable_bind_addr(bind_addr)); + let request = websocket_request(url.as_str(), bearer_token, /*origin*/ None)?; let deadline = Instant::now() + DEFAULT_READ_TIMEOUT; loop { - if socket_path.exists() { - return Ok((process, socket_path, socket_dir)); + match connect_async(request.clone()).await { + Ok((stream, _response)) => return Ok(stream), + Err(err) => { + if Instant::now() >= deadline { + bail!("failed to connect websocket to {url}: {err}"); + } + sleep(Duration::from_millis(50)).await; + } } - if let Some(status) = process.try_wait()? { - bail!("app-server exited before creating control socket: {status}"); - } - if Instant::now() >= deadline { - bail!( - "timed out waiting for app-server control socket at {}", - socket_path.display() - ); - } - sleep(Duration::from_millis(50)).await; } } -pub(super) async fn connect_websocket(socket_path: &Path) -> Result { +async fn assert_websocket_connect_rejected( + bind_addr: SocketAddr, + bearer_token: Option<&str>, +) -> Result<()> { + assert_websocket_connect_rejected_with_headers( + bind_addr, + bearer_token, + /*origin*/ None, + StatusCode::UNAUTHORIZED, + ) + .await +} + +async fn assert_websocket_connect_rejected_with_headers( + bind_addr: SocketAddr, + bearer_token: Option<&str>, + origin: Option<&str>, + expected_status: StatusCode, +) -> Result<()> { + let url = format!("ws://{}", connectable_bind_addr(bind_addr)); + let request = websocket_request(url.as_str(), bearer_token, origin)?; + + match connect_async(request).await { + Ok((_stream, response)) => { + bail!( + "expected websocket handshake rejection, got {}", + response.status() + ) + } + Err(WsError::Http(response)) => { + assert_eq!(response.status(), expected_status); + Ok(()) + } + Err(err) => bail!("expected http rejection during websocket handshake: {err}"), + } +} + +async fn run_websocket_server_to_completion_with_args( + codex_home: &Path, + listen_url: &str, + extra_args: &[String], +) -> Result { + let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") + .context("should find app-server binary")?; + let mut cmd = Command::new(program); + cmd.arg("--listen") + .arg(listen_url) + .arg(DISABLE_PLUGIN_STARTUP_TASKS_ARG) + .args(extra_args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .env("CODEX_HOME", codex_home) + .env("RUST_LOG", "warn"); + timeout(DEFAULT_READ_TIMEOUT, cmd.output()) + .await + .context("timed out waiting for websocket app-server to exit")? + .context("failed to run websocket app-server") +} + +async fn http_get( + client: &reqwest::Client, + bind_addr: SocketAddr, + path: &str, +) -> Result { + let connectable_bind_addr = connectable_bind_addr(bind_addr); let deadline = Instant::now() + DEFAULT_READ_TIMEOUT; loop { - match UnixStream::connect(socket_path).await { - Ok(stream) => match client_async("ws://localhost/rpc", stream).await { - Ok((websocket, _response)) => return Ok(websocket), - Err(err) => { - if Instant::now() >= deadline { - bail!( - "failed to upgrade websocket over {}: {err}", - socket_path.display() - ); - } - } - }, + match client + .get(format!("http://{connectable_bind_addr}{path}")) + .send() + .await + .with_context(|| format!("failed to GET http://{connectable_bind_addr}{path}")) + { + Ok(response) => return Ok(response), Err(err) => { if Instant::now() >= deadline { - bail!("failed to connect to {}: {err}", socket_path.display()); + bail!("failed to GET http://{connectable_bind_addr}{path}: {err}"); } + sleep(Duration::from_millis(50)).await; } } - sleep(Duration::from_millis(50)).await; } } +fn websocket_request( + url: &str, + bearer_token: Option<&str>, + origin: Option<&str>, +) -> Result> { + let mut request = url + .into_client_request() + .context("failed to create websocket request")?; + if let Some(bearer_token) = bearer_token { + request.headers_mut().insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {bearer_token}")) + .context("invalid bearer token header")?, + ); + } + if let Some(origin) = origin { + request.headers_mut().insert( + ORIGIN, + HeaderValue::from_str(origin).context("invalid origin header")?, + ); + } + Ok(request) +} + pub(super) async fn send_initialize_request( stream: &mut WsClient, id: i64, @@ -467,3 +849,25 @@ stream_max_retries = 0 ), ) } + +fn connectable_bind_addr(bind_addr: SocketAddr) -> SocketAddr { + match bind_addr { + SocketAddr::V4(addr) if addr.ip().is_unspecified() => { + SocketAddr::from(([127, 0, 0, 1], addr.port())) + } + SocketAddr::V6(addr) if addr.ip().is_unspecified() => { + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], addr.port())) + } + _ => bind_addr, + } +} + +fn signed_bearer_token(shared_secret: &[u8], claims: serde_json::Value) -> Result { + let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); + let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?); + let payload = format!("{header_segment}.{claims_segment}"); + let mut mac = HmacSha256::new_from_slice(shared_secret).context("failed to create hmac")?; + mac.update(payload.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + Ok(format!("{payload}.{signature}")) +} diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs index dcc7b60164..4b708d4ab3 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs @@ -32,13 +32,12 @@ use wiremock::matchers::method; use wiremock::matchers::path_regex; #[tokio::test] -async fn unix_socket_transport_ctrl_c_waits_for_running_turn_before_exit() -> Result<()> { +async fn websocket_transport_ctrl_c_waits_for_running_turn_before_exit() -> Result<()> { let GracefulCtrlCFixture { _codex_home, _server, mut process, mut ws, - .. } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; send_sigint(&process)?; @@ -58,13 +57,12 @@ async fn unix_socket_transport_ctrl_c_waits_for_running_turn_before_exit() -> Re } #[tokio::test] -async fn unix_socket_transport_second_ctrl_c_forces_exit_while_turn_running() -> Result<()> { +async fn websocket_transport_second_ctrl_c_forces_exit_while_turn_running() -> Result<()> { let GracefulCtrlCFixture { _codex_home, _server, mut process, mut ws, - .. } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; send_sigint(&process)?; @@ -85,13 +83,12 @@ async fn unix_socket_transport_second_ctrl_c_forces_exit_while_turn_running() -> } #[tokio::test] -async fn unix_socket_transport_sigterm_waits_for_running_turn_before_exit() -> Result<()> { +async fn websocket_transport_sigterm_waits_for_running_turn_before_exit() -> Result<()> { let GracefulCtrlCFixture { _codex_home, _server, mut process, mut ws, - .. } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; send_sigterm(&process)?; @@ -111,13 +108,12 @@ async fn unix_socket_transport_sigterm_waits_for_running_turn_before_exit() -> R } #[tokio::test] -async fn unix_socket_transport_second_sigterm_forces_exit_while_turn_running() -> Result<()> { +async fn websocket_transport_second_sigterm_forces_exit_while_turn_running() -> Result<()> { let GracefulCtrlCFixture { _codex_home, _server, mut process, mut ws, - .. } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; send_sigterm(&process)?; @@ -144,7 +140,6 @@ async fn websocket_transport_repeated_sighup_keeps_waiting_for_running_turn() -> _server, mut process, mut ws, - .. } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; send_sighup(&process)?; @@ -168,7 +163,6 @@ async fn websocket_transport_repeated_sighup_keeps_waiting_for_running_turn() -> struct GracefulCtrlCFixture { _codex_home: TempDir, - _socket_dir: TempDir, _server: wiremock::MockServer, process: Child, ws: WsClient, @@ -187,8 +181,8 @@ async fn start_ctrl_c_restart_fixture(turn_delay: Duration) -> Result Result Result<()> { } fn send_signal(process: &Child, signal: &str) -> Result<()> { - let pid = process.id().context("app-server process has no pid")?; + let pid = process + .id() + .context("websocket app-server process has no pid")?; let status = StdCommand::new("kill") .arg(signal) .arg(pid.to_string()) @@ -304,7 +299,7 @@ async fn wait_for_process_exit_within( timeout(window, process.wait()) .await .context(timeout_context)? - .context("failed waiting for app-server process exit") + .context("failed waiting for websocket app-server process exit") } async fn expect_websocket_disconnect(stream: &mut WsClient) -> Result<()> { diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index f3ce4cbf2d..15675257fd 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -107,6 +107,29 @@ enabled = true Ok(()) } +fn write_project_hook_config(dot_codex_folder: &std::path::Path, command: &str) -> Result<()> { + std::fs::create_dir_all(dot_codex_folder)?; + std::fs::write( + dot_codex_folder.join("config.toml"), + format!( + r#"[features] +hooks = true + +[hooks] + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "{command}" +timeout = 5 +"# + ), + )?; + Ok(()) +} + #[tokio::test] async fn hooks_list_shows_discovered_hook() -> Result<()> { let codex_home = TempDir::new()?; @@ -368,6 +391,88 @@ timeout = 5 Ok(()) } +#[tokio::test] +async fn hooks_list_uses_root_repo_hooks_for_linked_worktrees() -> Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let repo_root = workspace.path().join("repo"); + let worktree_root = workspace.path().join("worktree"); + let worktree_git_dir = repo_root.join(".git/worktrees/feature-x"); + + std::fs::create_dir_all(&worktree_git_dir)?; + std::fs::create_dir_all(&worktree_root)?; + std::fs::write( + worktree_root.join(".git"), + format!("gitdir: {}\n", worktree_git_dir.display()), + )?; + write_project_hook_config(&repo_root.join(".codex"), "echo root hook")?; + write_project_hook_config(&worktree_root.join(".codex"), "echo worktree hook")?; + set_project_trust_level(codex_home.path(), &repo_root, TrustLevel::Trusted)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![repo_root.clone(), worktree_root.clone()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let repo_hook = data[0].hooks[0].clone(); + let worktree_hook = data[1].hooks[0].clone(); + let repo_config_path = + AbsolutePathBuf::from_absolute_path(repo_root.join(".codex/config.toml"))?; + + assert_eq!(repo_hook.command.as_deref(), Some("echo root hook")); + assert_eq!(worktree_hook.command.as_deref(), Some("echo root hook")); + assert_eq!(repo_hook.key, worktree_hook.key); + assert_eq!(repo_hook.source_path, repo_config_path); + assert_eq!(worktree_hook.source_path, repo_config_path); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.state".to_string(), + value: serde_json::json!({ + repo_hook.key.clone(): { + "trusted_hash": repo_hook.current_hash.clone() + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![worktree_root], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + assert_eq!(data[0].hooks[0].trust_status, HookTrustStatus::Trusted); + + Ok(()) +} + #[tokio::test] async fn config_batch_write_toggles_user_hook() -> Result<()> { let codex_home = TempDir::new()?; 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 a51f4bbd4e..bddf57f666 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -200,6 +200,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 6576ba87b9..0b2200a9d5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -241,6 +241,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ plugins: vec![PluginSummary { id: "valid-plugin@valid-marketplace".to_string(), remote_plugin_id: None, + local_version: None, name: "valid-plugin".to_string(), share_context: None, source: PluginSource::Local { @@ -531,6 +532,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab PluginSummary { id: "valid-plugin@alternate-marketplace".to_string(), remote_plugin_id: None, + local_version: None, name: "valid-plugin".to_string(), share_context: None, source: PluginSource::Local { @@ -565,6 +567,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab PluginSummary { id: "missing-plugin@alternate-marketplace".to_string(), remote_plugin_id: None, + local_version: None, name: "missing-plugin".to_string(), share_context: None, source: PluginSource::Local { @@ -660,7 +663,7 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<( )?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"demo-plugin"}"#, + r#"{"name":"demo-plugin","version":"1.2.3"}"#, )?; write_plugin_share_local_path_mapping( codex_home.path(), @@ -692,11 +695,13 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<( .find(|plugin| plugin.name == "demo-plugin") .expect("expected demo-plugin entry"); assert_eq!(plugin.remote_plugin_id, None); + assert_eq!(plugin.local_version.as_deref(), Some("1.2.3")); let share_context = plugin .share_context .as_ref() .expect("expected share context"); assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.remote_version, None); assert_eq!(share_context.discoverability, None); assert_eq!(share_context.share_url, None); assert_eq!(share_context.creator_account_user_id, None); @@ -1819,13 +1824,27 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { ))?; shared_plugin_body["plugins"][0]["share_principals"] = serde_json::Value::Null; let shared_plugin_body = serde_json::to_string(&shared_plugin_body)?; - let workspace_installed_body = workspace_remote_plugin_page_body( - "plugins~Plugin_22222222222222222222222222222222", - "shared-linear", - "Shared Linear", - "PRIVATE", - /*enabled*/ Some(true), - ); + let mut workspace_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + "PRIVATE", + /*enabled*/ Some(true), + ))?; + let unlisted_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_33333333333333333333333333333333", + "unlisted-linear", + "Unlisted Linear", + "UNLISTED", + /*enabled*/ Some(false), + ))?; + workspace_installed_body["plugins"] + .as_array_mut() + .expect("installed plugins should be an array") + .push(unlisted_installed_body["plugins"][0].clone()); + let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?; mount_shared_workspace_plugins(&server, &shared_plugin_body).await; mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; @@ -1846,9 +1865,12 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { .await??; let response: PluginListResponse = to_response(response)?; - assert_eq!(response.marketplaces.len(), 1); - let marketplace = &response.marketplaces[0]; - assert_eq!(marketplace.name, "shared-with-me"); + assert_eq!(response.marketplaces.len(), 2); + let marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "workspace-shared-with-me-private") + .expect("expected private shared-with-me marketplace"); assert_eq!( marketplace .interface @@ -1857,7 +1879,10 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { Some("Shared with me") ); assert_eq!(marketplace.plugins.len(), 1); - assert_eq!(marketplace.plugins[0].id, "shared-linear@shared-with-me"); + assert_eq!( + marketplace.plugins[0].id, + "shared-linear@workspace-shared-with-me-private" + ); assert_eq!( marketplace.plugins[0].remote_plugin_id.as_deref(), Some("plugins~Plugin_22222222222222222222222222222222") @@ -1873,6 +1898,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { share_context.remote_plugin_id, "plugins~Plugin_22222222222222222222222222222222" ); + assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3")); assert_eq!( share_context.discoverability, Some(PluginShareDiscoverability::Private) @@ -1887,10 +1913,107 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { Some("https://chatgpt.example/plugins/share/share-key-1") ); assert_eq!(share_context.share_principals, None); + + let marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "workspace-shared-with-me-unlisted") + .expect("expected unlisted shared-with-me marketplace"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Shared with me (unlisted)") + ); + assert_eq!(marketplace.plugins.len(), 1); + assert_eq!( + marketplace.plugins[0].id, + "unlisted-linear@workspace-shared-with-me-unlisted" + ); + assert_eq!( + marketplace.plugins[0].remote_plugin_id.as_deref(), + Some("plugins~Plugin_33333333333333333333333333333333") + ); + assert_eq!(marketplace.plugins[0].name, "unlisted-linear"); + assert_eq!(marketplace.plugins[0].installed, true); + assert_eq!(marketplace.plugins[0].enabled, false); + let share_context = marketplace.plugins[0] + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!( + share_context.remote_plugin_id, + "plugins~Plugin_33333333333333333333333333333333" + ); + assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3")); + assert_eq!( + share_context.discoverability, + Some(PluginShareDiscoverability::Unlisted) + ); wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; Ok(()) } +#[tokio::test] +async fn plugin_list_omits_shared_with_me_kind_when_plugin_sharing_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +plugin_sharing = false +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::SharedWithMe]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!( + response, + PluginListResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + } + ); + wait_for_remote_plugin_request_count( + &server, + "/ps/plugins/workspace/shared", + /*expected_count*/ 0, + ) + .await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { let codex_home = TempDir::new()?; @@ -2434,6 +2557,7 @@ fn workspace_remote_plugin_page_body( }} ], "release": {{ + "version": "1.2.3", "display_name": "{display_name}", "description": "Track work", "app_ids": [], diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 62294b2f95..525a4234aa 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -262,6 +262,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { + "version": "2.3.4", "display_name": "Shared Linear", "description": "Track shared work", "app_ids": [], @@ -302,7 +303,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("shared-with-me".to_string()), + remote_marketplace_name: Some("workspace-shared-with-me-private".to_string()), plugin_name: "plugins~Plugin_11111111111111111111111111111111".to_string(), }) .await?; @@ -314,8 +315,14 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< .await??; let response: PluginReadResponse = to_response(response)?; - assert_eq!(response.plugin.marketplace_name, "shared-with-me"); - assert_eq!(response.plugin.summary.id, "shared-linear@shared-with-me"); + assert_eq!( + response.plugin.marketplace_name, + "workspace-shared-with-me-private" + ); + assert_eq!( + response.plugin.summary.id, + "shared-linear@workspace-shared-with-me-private" + ); assert_eq!( response.plugin.summary.remote_plugin_id.as_deref(), Some("plugins~Plugin_11111111111111111111111111111111") @@ -330,6 +337,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< share_context.remote_plugin_id, "plugins~Plugin_11111111111111111111111111111111" ); + assert_eq!(share_context.remote_version.as_deref(), Some("2.3.4")); assert_eq!( share_context.discoverability, Some(PluginShareDiscoverability::Private) @@ -800,7 +808,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( repo_root .path() .join("demo-plugin/.codex-plugin/plugin.json"), - r#"{"name":"demo-plugin"}"#, + r#"{"name":"demo-plugin","version":"1.2.3"}"#, )?; let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; @@ -833,6 +841,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { + "version": "1.2.4", "display_name": "Demo Plugin", "description": "Shared local plugin", "app_ids": [], @@ -865,6 +874,10 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.summary.remote_plugin_id, None); + assert_eq!( + response.plugin.summary.local_version.as_deref(), + Some("1.2.3") + ); let share_context = response .plugin .summary @@ -872,6 +885,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( .as_ref() .expect("expected share context"); assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4")); assert_eq!( share_context.discoverability, Some(PluginShareDiscoverability::Unlisted) @@ -905,6 +919,107 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( Ok(()) } +#[tokio::test] +async fn plugin_read_keeps_remote_version_when_share_principals_are_missing() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + write_plugin_marketplace( + repo_root.path(), + "codex-curated", + "demo-plugin", + "./demo-plugin", + )?; + std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?; + std::fs::write( + repo_root + .path() + .join("demo-plugin/.codex-plugin/plugin.json"), + r#"{"name":"demo-plugin","version":"1.2.3"}"#, + )?; + let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; + write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/plugins_123")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "plugins_123", + "name": "demo-plugin", + "scope": "WORKSPACE", + "discoverability": "UNLISTED", + "creator_account_user_id": "user-owner__account-123", + "creator_name": "Owner", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "share_principals": null, + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "version": "1.2.4", + "display_name": "Demo Plugin", + "description": "Shared local plugin", + "app_ids": [], + "keywords": [], + "interface": {}, + "skills": [] + } + }))) + .expect(1) + .mount(&server) + .await; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!(response.plugin.summary.remote_plugin_id, None); + assert_eq!( + response.plugin.summary.local_version.as_deref(), + Some("1.2.3") + ); + let share_context = response + .plugin + .summary + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4")); + assert_eq!(share_context.discoverability, None); + assert_eq!(share_context.share_url, None); + assert_eq!(share_context.creator_account_user_id, None); + assert_eq!(share_context.creator_name, None); + assert_eq!(share_context.share_principals, None); + Ok(()) +} + #[tokio::test] async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> Result<()> { let codex_home = TempDir::new()?; @@ -941,6 +1056,7 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.summary.remote_plugin_id, None); + assert_eq!(response.plugin.summary.local_version, None); let share_context = response .plugin .summary @@ -948,6 +1064,7 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> .as_ref() .expect("expected share context"); assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.remote_version, None); assert_eq!(share_context.discoverability, None); assert_eq!(share_context.share_url, None); assert_eq!(share_context.creator_account_user_id, None); diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index 46f3dceb5e..efe3d291a4 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -12,6 +12,9 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginListParams; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginShareCheckoutResponse; use codex_app_server_protocol::PluginShareContext; use codex_app_server_protocol::PluginShareDeleteResponse; use codex_app_server_protocol::PluginShareDiscoverability; @@ -27,6 +30,8 @@ use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; +use flate2::Compression; +use flate2::write::GzEncoder; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; @@ -41,6 +46,8 @@ use wiremock::matchers::path; use wiremock::matchers::query_param; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str = + "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS"; #[tokio::test] async fn plugin_share_save_uploads_local_plugin() -> Result<()> { @@ -162,8 +169,9 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "demo-plugin@shared-with-me".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), remote_plugin_id: Some("plugins_123".to_string()), + local_version: None, name: "demo-plugin".to_string(), share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, @@ -322,6 +330,64 @@ async fn plugin_share_save_rejects_listed_discoverability() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_save_rejects_when_plugin_sharing_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{}/backend-api" + +[features] +plugins = true +remote_plugin = true +plugin_sharing = false +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!(error.error.message, "plugin sharing is disabled"); + assert!( + server + .received_requests() + .await + .expect("wiremock should record requests") + .is_empty() + ); + Ok(()) +} + #[tokio::test] async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> { let codex_home = TempDir::new()?; @@ -507,8 +573,9 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "demo-plugin@shared-with-me".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), remote_plugin_id: Some("plugins_123".to_string()), + local_version: None, name: "demo-plugin".to_string(), share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, @@ -527,6 +594,335 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_checkout_adds_personal_marketplace_entry() -> Result<()> { + let codex_home = TempDir::new()?; + let home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let bundle_url = mount_remote_plugin_bundle( + &server, + "demo-plugin", + remote_plugin_bundle_tar_gz_bytes("demo-plugin")?, + ) + .await; + mount_remote_plugin_detail_with_bundle( + &server, + "plugins_123", + "demo-plugin", + &bundle_url, + "WORKSPACE", + ) + .await; + mount_empty_remote_installed_plugins(&server, "WORKSPACE").await; + + let home_env = home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home_env.as_str())), + ("USERPROFILE", Some(home_env.as_str())), + (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "plugin/share/checkout", + Some(json!({ + "remotePluginId": "plugins_123", + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareCheckoutResponse = to_response(response)?; + + let plugin_path = AbsolutePathBuf::try_from(home.path().join("plugins/demo-plugin"))?; + let marketplace_path = + AbsolutePathBuf::try_from(home.path().join(".agents/plugins/marketplace.json"))?; + assert_eq!( + response, + PluginShareCheckoutResponse { + remote_plugin_id: "plugins_123".to_string(), + plugin_id: "demo-plugin@codex-curated".to_string(), + plugin_name: "demo-plugin".to_string(), + plugin_path: plugin_path.clone(), + marketplace_name: "codex-curated".to_string(), + marketplace_path: marketplace_path.clone(), + remote_version: Some("1.2.3".to_string()), + } + ); + assert!( + plugin_path + .as_path() + .join(".codex-plugin/plugin.json") + .is_file() + ); + + let marketplace: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(marketplace_path.as_path())?)?; + assert_eq!( + marketplace, + json!({ + "name": "codex-curated", + "interface": { + "displayName": "Personal", + }, + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin", + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_USE", + }, + }, + ], + }) + ); + + let mapping: serde_json::Value = serde_json::from_str(&std::fs::read_to_string( + codex_home + .path() + .join(".tmp/plugin-share-local-paths-v1.json"), + )?)?; + assert_eq!( + mapping, + json!({ + "localPluginPathsByRemotePluginId": { + "plugins_123": plugin_path.clone(), + }, + }) + ); + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![ + codex_app_server_protocol::PluginListMarketplaceKind::Local, + ]), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "codex-curated"); + assert_eq!(response.marketplaces[0].plugins[0].name, "demo-plugin"); + assert_eq!( + response.marketplaces[0].plugins[0] + .share_context + .as_ref() + .map(|context| context.remote_plugin_id.as_str()), + Some("plugins_123") + ); + + std::fs::write(plugin_path.as_path().join("local-edit.txt"), "keep")?; + let request_id = mcp + .send_raw_request( + "plugin/share/checkout", + Some(json!({ + "remotePluginId": "plugins_123", + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareCheckoutResponse = to_response(response)?; + assert_eq!(response.plugin_path, plugin_path); + assert_eq!( + std::fs::read_to_string(plugin_path.as_path().join("local-edit.txt"))?, + "keep" + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_share_checkout_rejects_non_share_remote_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let bundle_url = format!("{}/bundles/global-plugin.tar.gz", server.uri()); + mount_remote_plugin_detail_with_bundle( + &server, + "plugins_global", + "global-plugin", + &bundle_url, + "GLOBAL", + ) + .await; + mount_empty_remote_installed_plugins(&server, "GLOBAL").await; + + let home_env = home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home_env.as_str())), + ("USERPROFILE", Some(home_env.as_str())), + (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "plugin/share/checkout", + Some(json!({ + "remotePluginId": "plugins_global", + })), + ) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert!( + error + .error + .message + .contains("not available for plugin/share/checkout") + ); + assert!(!home.path().join("plugins/global-plugin").exists()); + + Ok(()) +} + +#[tokio::test] +async fn plugin_share_checkout_cleans_up_path_when_marketplace_update_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let marketplace_path = home.path().join(".agents/plugins/marketplace.json"); + std::fs::create_dir_all( + marketplace_path + .parent() + .expect("marketplace path has parent"), + )?; + std::fs::write( + &marketplace_path, + serde_json::to_string_pretty(&json!({ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./other/demo-plugin", + }, + }, + ], + }))?, + )?; + + let bundle_url = mount_remote_plugin_bundle( + &server, + "demo-plugin", + remote_plugin_bundle_tar_gz_bytes("demo-plugin")?, + ) + .await; + mount_remote_plugin_detail_with_bundle( + &server, + "plugins_123", + "demo-plugin", + &bundle_url, + "WORKSPACE", + ) + .await; + mount_empty_remote_installed_plugins(&server, "WORKSPACE").await; + + let home_env = home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home_env.as_str())), + ("USERPROFILE", Some(home_env.as_str())), + (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "plugin/share/checkout", + Some(json!({ + "remotePluginId": "plugins_123", + })), + ) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert!( + error + .error + .message + .contains("marketplace already contains plugin `demo-plugin`") + ); + assert!(!home.path().join("plugins/demo-plugin").exists()); + assert!( + !codex_home + .path() + .join(".tmp/plugin-share-local-paths-v1.json") + .exists() + ); + + Ok(()) +} + #[tokio::test] async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { let codex_home = TempDir::new()?; @@ -727,8 +1123,9 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "demo-plugin@shared-with-me".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), remote_plugin_id: Some("plugins_123".to_string()), + local_version: None, name: "demo-plugin".to_string(), share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, @@ -762,6 +1159,85 @@ remote_plugin = true ) } +async fn mount_remote_plugin_bundle( + server: &MockServer, + plugin_name: &str, + body: Vec, +) -> String { + let bundle_path = format!("/bundles/{plugin_name}.tar.gz"); + Mock::given(method("GET")) + .and(path(bundle_path.clone())) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/gzip") + .set_body_bytes(body), + ) + .expect(1) + .mount(server) + .await; + format!("{}{}", server.uri(), bundle_path) +} + +async fn mount_remote_plugin_detail_with_bundle( + server: &MockServer, + remote_plugin_id: &str, + plugin_name: &str, + bundle_url: &str, + scope: &str, +) { + Mock::given(method("GET")) + .and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}"))) + .and(query_param("includeDownloadUrls", "true")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": remote_plugin_id, + "name": plugin_name, + "scope": scope, + "discoverability": "PRIVATE", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "share_principals": [ + { + "principal_type": "user", + "principal_id": "user-owner__account-123", + "role": "owner", + "name": "Owner", + }, + ], + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "version": "1.2.3", + "bundle_download_url": bundle_url, + "display_name": "Demo Plugin", + "description": "Demo plugin description", + "interface": { + "short_description": "A demo plugin", + "capabilities": ["Read", "Write"], + }, + "skills": [], + }, + }))) + .mount(server) + .await; +} + +async fn mount_empty_remote_installed_plugins(server: &MockServer, scope: &str) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", scope)) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [], + "pagination": { + "next_page_token": null, + }, + }))) + .mount(server) + .await; +} + fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { json!({ "id": plugin_id, @@ -786,6 +1262,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { + "version": "0.1.0", "display_name": "Demo Plugin", "description": "Demo plugin description", "interface": { @@ -838,6 +1315,7 @@ fn expected_plugin_interface() -> PluginInterface { fn expected_share_context(plugin_id: &str) -> PluginShareContext { PluginShareContext { remote_plugin_id: plugin_id.to_string(), + remote_version: Some("0.1.0".to_string()), discoverability: Some(PluginShareDiscoverability::Private), share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), creator_account_user_id: None, @@ -872,6 +1350,32 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result Ok(plugin_path) } +fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { + let manifest = format!(r#"{{"name":"{plugin_name}"}}"#); + let skill = "# Example\n\nA test skill.\n"; + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut tar = tar::Builder::new(encoder); + for (path, contents, mode) in [ + ( + ".codex-plugin/plugin.json", + manifest.as_bytes(), + /*mode*/ 0o644, + ), + ( + "skills/example/SKILL.md", + skill.as_bytes(), + /*mode*/ 0o644, + ), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(mode); + header.set_cksum(); + tar.append_data(&mut header, path, contents)?; + } + Ok(tar.into_inner()?.finish()?) +} + fn write_corrupt_plugin_share_local_path_mapping(codex_home: &Path) -> std::io::Result<()> { write_file( &codex_home.join(".tmp/plugin-share-local-paths-v1.json"), 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 e5c0b2c53f..9787868f5c 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 @@ -75,6 +75,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: CodexFeedback::new(), 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 2382d8fe3d..951e4d74e9 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 @@ -36,11 +36,11 @@ async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> { create_config_toml(codex_home.path(), &server.uri(), "never")?; let conversation_id = create_rollout(codex_home.path(), "2025-01-05T12-00-00")?; - let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?; + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; let result = async { - let mut ws1 = connect_websocket(&socket_path).await?; - let mut ws2 = connect_websocket(&socket_path).await?; + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; initialize_both_clients(&mut ws1, &mut ws2).await?; send_request( @@ -91,7 +91,7 @@ async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> { process .kill() .await - .context("failed to stop app-server process")?; + .context("failed to stop websocket app-server process")?; result } @@ -102,11 +102,11 @@ async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> { create_config_toml(codex_home.path(), &server.uri(), "never")?; let conversation_id = create_rollout(codex_home.path(), "2025-01-05T12-05-00")?; - let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?; + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; let result = async { - let mut ws1 = connect_websocket(&socket_path).await?; - let mut ws2 = connect_websocket(&socket_path).await?; + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; initialize_both_clients(&mut ws1, &mut ws2).await?; let renamed = "Stored rename"; @@ -143,7 +143,7 @@ async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> { process .kill() .await - .context("failed to stop app-server process")?; + .context("failed to stop websocket app-server process")?; result } 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 52420c0c80..fa143254a5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -375,6 +375,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), @@ -440,6 +441,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), @@ -525,6 +527,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), @@ -1304,7 +1307,7 @@ async fn seed_pathless_store_thread( .update_thread_metadata(UpdateThreadMetadataParams { thread_id, patch: ThreadMetadataPatch { - name: Some("named pathless thread".to_string()), + name: Some(Some("named pathless thread".to_string())), ..Default::default() }, include_archived: true, 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 5b421dcec5..d2f8f268a2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -226,7 +226,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { .update_thread_metadata(UpdateThreadMetadataParams { thread_id, patch: ThreadMetadataPatch { - name: Some("named pathless thread".to_string()), + name: Some(Some("named pathless thread".to_string())), ..Default::default() }, include_archived: true, @@ -245,6 +245,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 2722f69849..30c60d7b95 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -190,6 +190,7 @@ async fn run_command_under_sandbox( .map_err(anyhow::Error::msg)?, codex_linux_sandbox_exe, config_options, + /*strict_config*/ false, ) .await?; @@ -636,12 +637,14 @@ async fn load_debug_sandbox_config( cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, options: DebugSandboxConfigOptions, + strict_config: bool, ) -> anyhow::Result { load_debug_sandbox_config_with_codex_home( cli_overrides, codex_linux_sandbox_exe, options, /*codex_home*/ None, + strict_config, ) .await } @@ -651,6 +654,7 @@ async fn load_debug_sandbox_config_with_codex_home( codex_linux_sandbox_exe: Option, options: DebugSandboxConfigOptions, codex_home: Option, + strict_config: bool, ) -> anyhow::Result { let DebugSandboxConfigOptions { permissions_profile, @@ -680,6 +684,7 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home.clone(), managed_requirements_mode, + strict_config, ) .await?; @@ -697,6 +702,7 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home, managed_requirements_mode, + strict_config, ) .await .map_err(Into::into) @@ -707,14 +713,16 @@ async fn build_debug_sandbox_config( harness_overrides: ConfigOverrides, codex_home: Option, managed_requirements_mode: ManagedRequirementsMode, + strict_config: bool, ) -> std::io::Result { let mut builder = ConfigBuilder::default() .cli_overrides(cli_overrides) - .harness_overrides(harness_overrides); - if let ManagedRequirementsMode::Ignore = managed_requirements_mode { + .harness_overrides(harness_overrides) + .strict_config(strict_config); + if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) { builder = builder.loader_overrides(LoaderOverrides { ignore_managed_requirements: true, - ..Default::default() + ..LoaderOverrides::default() }); } if let Some(codex_home) = codex_home { @@ -783,6 +791,7 @@ mod tests { ConfigOverrides::default(), Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; let legacy_config = build_debug_sandbox_config( @@ -793,6 +802,7 @@ mod tests { }, Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; @@ -805,6 +815,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Include, }, Some(codex_home_path), + /*strict_config*/ false, ) .await?; @@ -840,6 +851,7 @@ mod tests { ConfigOverrides::default(), Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; let read_only_config = build_debug_sandbox_config( @@ -850,6 +862,7 @@ mod tests { }, Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; @@ -862,6 +875,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Include, }, Some(codex_home_path), + /*strict_config*/ false, ) .await?; @@ -905,6 +919,7 @@ mod tests { }, Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; @@ -917,6 +932,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Include, }, Some(codex_home_path), + /*strict_config*/ false, ) .await?; @@ -942,6 +958,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Ignore, }, Some(codex_home.path().to_path_buf()), + /*strict_config*/ false, ) .await?; @@ -975,6 +992,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Ignore, }, Some(codex_home.path().to_path_buf()), + /*strict_config*/ false, ) .await?; @@ -1004,6 +1022,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Ignore, }, Some(codex_home.path().to_path_buf()), + /*strict_config*/ false, ) .await?; @@ -1015,6 +1034,7 @@ mod tests { ConfigOverrides::default(), Some(codex_home.path().to_path_buf()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; @@ -1040,6 +1060,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Ignore, }, Some(codex_home.path().to_path_buf()), + /*strict_config*/ false, ) .await?; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index fba2c479cd..ef54aa52cd 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -55,7 +55,7 @@ use crate::marketplace_cmd::MarketplaceCli; use crate::mcp_cmd::McpCli; use codex_core::build_models_manager; -use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::find_codex_home; @@ -109,7 +109,7 @@ enum Subcommand { Exec(ExecCli), /// Run a code review non-interactively. - Review(ReviewArgs), + Review(ReviewCommand), /// Manage login. Login(LoginCommand), @@ -124,7 +124,7 @@ enum Subcommand { Plugin(PluginCli), /// Start Codex as an MCP server (stdio). - McpServer, + McpServer(McpServerCommand), /// [experimental] Run the app server or related tooling. AppServer(AppServerCommand), @@ -266,6 +266,23 @@ struct DebugModelsCommand { bundled: bool, } +#[derive(Debug, Parser)] +struct ReviewCommand { + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, + + #[clap(flatten)] + args: ReviewArgs, +} + +#[derive(Debug, Parser)] +struct McpServerCommand { + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, +} + #[derive(Debug, Parser)] struct DebugTraceReduceCommand { /// Trace bundle directory containing manifest.json and trace.jsonl. @@ -419,8 +436,12 @@ struct AppServerCommand { #[command(subcommand)] subcommand: Option, + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, + /// Transport endpoint URL. Supported values: `stdio://` (default), - /// `unix://`, `unix://PATH`, `off`. + /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. #[arg( long = "listen", value_name = "URL", @@ -445,6 +466,9 @@ struct AppServerCommand { /// See https://developers.openai.com/codex/config-advanced/#metrics for more details. #[arg(long = "analytics-default-enabled")] analytics_default_enabled: bool, + + #[command(flatten)] + auth: codex_app_server::AppServerWebsocketAuthArgs, } #[derive(Debug, Parser)] @@ -715,9 +739,9 @@ struct FeatureToggles { #[derive(Debug, Default, Parser, Clone)] struct InteractiveRemoteOptions { - /// Connect the TUI to a remote app server websocket endpoint. + /// Connect the TUI to a remote app server endpoint. /// - /// Accepted forms: `ws://host:port` or `wss://host:port`. + /// Accepted forms: `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`. #[arg(long = "remote", value_name = "ADDR")] remote: Option, @@ -803,6 +827,8 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_config_overrides.raw_overrides.extend(toggle_overrides); let root_remote = remote.remote; let root_remote_auth_token_env = remote.remote_auth_token_env; + let root_strict_config = interactive.strict_config; + reject_root_strict_config_for_subcommand(root_strict_config, &subcommand)?; match subcommand { None => { @@ -828,13 +854,17 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { exec_cli .shared .inherit_exec_root_options(&interactive.shared); + exec_cli.strict_config |= root_strict_config; prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), ); codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } - Some(Subcommand::Review(review_args)) => { + Some(Subcommand::Review(ReviewCommand { + strict_config, + args: review_args, + })) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), @@ -842,19 +872,25 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?; exec_cli.command = Some(ExecCommand::Review(review_args)); + exec_cli.strict_config = strict_config || root_strict_config; prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), ); codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } - Some(Subcommand::McpServer) => { + Some(Subcommand::McpServer(McpServerCommand { strict_config })) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), "mcp-server", )?; - codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?; + codex_mcp_server::run_main( + arg0_paths.clone(), + root_config_overrides, + strict_config || root_strict_config, + ) + .await?; } Some(Subcommand::Mcp(mut mcp_cli)) => { reject_remote_mode_for_subcommand( @@ -887,9 +923,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { Some(Subcommand::AppServer(app_server_cli)) => { let AppServerCommand { subcommand, + strict_config: app_server_strict_config, listen, analytics_default_enabled, + auth, } = app_server_cli; + let strict_config = app_server_strict_config || root_strict_config; + reject_strict_config_for_app_server_subcommand(strict_config, subcommand.as_ref())?; reject_remote_mode_for_app_server_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), @@ -898,13 +938,17 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { match subcommand { None => { let transport = listen; - codex_app_server::run_main_with_transport( + let auth = auth.try_into_settings()?; + codex_app_server::run_main_with_transport_options( arg0_paths.clone(), root_config_overrides, codex_config::LoaderOverrides::default(), + strict_config, analytics_default_enabled, transport, codex_protocol::protocol::SessionSource::VSCode, + auth, + codex_app_server::AppServerRuntimeOptions::default(), ) .await?; } @@ -1304,11 +1348,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { ..Default::default() }; - let config = Config::load_with_cli_overrides_and_harness_overrides( - cli_kv_overrides, - overrides, - ) - .await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .build() + .await?; let mut rows = Vec::with_capacity(FEATURES.len()); let mut name_width = 0; let mut stage_width = 0; @@ -1480,11 +1524,15 @@ async fn run_debug_prompt_input_command( main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe, show_raw_agent_reasoning: shared.oss.then_some(true), ephemeral: Some(true), + bypass_hook_trust: shared.bypass_hook_trust.then_some(true), additional_writable_roots: shared.add_dir, ..Default::default() }; - let config = - Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .build() + .await?; let mut input = shared .images @@ -1515,7 +1563,10 @@ async fn run_debug_models_command( let cli_overrides = root_config_overrides .parse_overrides() .map_err(anyhow::Error::msg)?; - let config = Config::load_with_cli_overrides(cli_overrides).await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_overrides) + .build() + .await?; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await; let models_manager = build_models_manager(&config, auth_manager); @@ -1540,8 +1591,11 @@ async fn run_debug_clear_memories_command( config_profile: interactive.config_profile.clone(), ..Default::default() }; - let config = - Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .build() + .await?; let state_path = state_db_path(config.sqlite_home.as_path()); let mut cleared_state_db = false; @@ -1576,9 +1630,7 @@ fn prepend_config_flags( subcommand_config_overrides: &mut CliConfigOverrides, cli_config_overrides: CliConfigOverrides, ) { - subcommand_config_overrides - .raw_overrides - .splice(0..0, cli_config_overrides.raw_overrides); + subcommand_config_overrides.prepend_root_overrides(cli_config_overrides); } fn reject_remote_mode_for_subcommand( @@ -1599,12 +1651,103 @@ fn reject_remote_mode_for_subcommand( Ok(()) } +fn reject_root_strict_config_for_subcommand( + strict_config: bool, + subcommand: &Option, +) -> anyhow::Result<()> { + if !strict_config { + return Ok(()); + } + + match unsupported_subcommand_name_for_strict_config(subcommand) { + Some(subcommand_name) => { + reject_strict_config_for_unsupported_subcommand(strict_config, subcommand_name) + } + None => Ok(()), + } +} + +/// Return the selected subcommand name when a root-level `--strict-config` +/// flag should be rejected after parsing. +/// +/// `--strict-config` is parsed on the root interactive CLI so commands like +/// `codex --strict-config` continue to work for the TUI and for wrappers that +/// forward root options into another command shape. Clap will still accept that +/// root flag before the dispatcher knows which subcommand the user selected, so +/// unsupported subcommands need an explicit post-parse reject path. +/// +/// `Some(...)` returns the user-facing command name fragment to embed in the +/// rejection error, such as `cloud` or `app-server proxy`. `None` means the +/// selected command is allowed to inherit root `--strict-config`. +fn unsupported_subcommand_name_for_strict_config( + subcommand: &Option, +) -> Option<&'static str> { + match subcommand { + None + | Some(Subcommand::Exec(_)) + | Some(Subcommand::Review(_)) + | Some(Subcommand::McpServer(_)) + | Some(Subcommand::Resume(_)) + | Some(Subcommand::Fork(_)) => None, + Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None, + Some(Subcommand::AppServer(app_server)) => { + Some(app_server_subcommand_name(app_server.subcommand.as_ref())) + } + Some(Subcommand::RemoteControl) => Some("remote-control"), + Some(Subcommand::Mcp(_)) => Some("mcp"), + Some(Subcommand::Plugin(_)) => Some("plugin"), + #[cfg(any(target_os = "macos", target_os = "windows"))] + Some(Subcommand::App(_)) => Some("app"), + Some(Subcommand::Login(_)) => Some("login"), + Some(Subcommand::Logout(_)) => Some("logout"), + Some(Subcommand::Completion(_)) => Some("completion"), + Some(Subcommand::Update) => Some("update"), + Some(Subcommand::Cloud(_)) => Some("cloud"), + Some(Subcommand::Sandbox(_)) => Some("sandbox"), + Some(Subcommand::Debug(_)) => Some("debug"), + Some(Subcommand::Execpolicy(_)) => Some("execpolicy"), + Some(Subcommand::Apply(_)) => Some("apply"), + Some(Subcommand::ResponsesApiProxy(_)) => Some("responses-api-proxy"), + Some(Subcommand::StdioToUds(_)) => Some("stdio-to-uds"), + Some(Subcommand::ExecServer(_)) => Some("exec-server"), + Some(Subcommand::Features(_)) => Some("features"), + } +} + +fn reject_strict_config_for_app_server_subcommand( + strict_config: bool, + subcommand: Option<&AppServerSubcommand>, +) -> anyhow::Result<()> { + if subcommand.is_none() { + return Ok(()); + } + reject_strict_config_for_unsupported_subcommand( + strict_config, + app_server_subcommand_name(subcommand), + ) +} + +fn reject_strict_config_for_unsupported_subcommand( + strict_config: bool, + subcommand: &str, +) -> anyhow::Result<()> { + if strict_config { + anyhow::bail!("`--strict-config` is not supported for `codex {subcommand}`"); + } + Ok(()) +} + fn reject_remote_mode_for_app_server_subcommand( remote: Option<&str>, remote_auth_token_env: Option<&str>, subcommand: Option<&AppServerSubcommand>, ) -> anyhow::Result<()> { - let subcommand_name = match subcommand { + let subcommand_name = app_server_subcommand_name(subcommand); + reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) +} + +fn app_server_subcommand_name(subcommand: Option<&AppServerSubcommand>) -> &'static str { + match subcommand { None => "app-server", Some(AppServerSubcommand::Daemon(daemon)) => match daemon.subcommand { AppServerDaemonSubcommand::Bootstrap(_) => "app-server daemon bootstrap", @@ -1626,8 +1769,7 @@ fn reject_remote_mode_for_app_server_subcommand( Some(AppServerSubcommand::GenerateInternalJsonSchema(_)) => { "app-server generate-internal-json-schema" } - }; - reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) + } } async fn print_app_server_daemon_output(command: AppServerLifecycleCommand) -> anyhow::Result<()> { @@ -1693,27 +1835,39 @@ async fn run_interactive_tui( } } - let normalized_remote = remote + let mut remote_endpoint = remote .as_deref() - .map(codex_tui::normalize_remote_addr) + .map(codex_tui::resolve_remote_addr) .transpose() .map_err(std::io::Error::other)?; - if remote_auth_token_env.is_some() && normalized_remote.is_none() { - return Ok(AppExitInfo::fatal( - "`--remote-auth-token-env` requires `--remote`.", - )); + if let Some(remote_auth_token_env) = remote_auth_token_env { + let Some(endpoint) = remote_endpoint.as_mut() else { + return Ok(AppExitInfo::fatal( + "`--remote-auth-token-env` requires `--remote`.", + )); + }; + if !codex_tui::remote_addr_supports_auth_token(endpoint) { + return Ok(AppExitInfo::fatal( + "`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.", + )); + } + let auth_token = read_remote_auth_token_from_env_var(&remote_auth_token_env) + .map_err(std::io::Error::other)?; + let codex_tui::RemoteAppServerEndpoint::WebSocket { + auth_token: slot, .. + } = endpoint + else { + return Ok(AppExitInfo::fatal( + "`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.", + )); + }; + *slot = Some(auth_token); } - let remote_auth_token = remote_auth_token_env - .as_deref() - .map(read_remote_auth_token_from_env_var) - .transpose() - .map_err(std::io::Error::other)?; codex_tui::run_main( interactive, arg0_paths, codex_config::LoaderOverrides::default(), - normalized_remote, - remote_auth_token, + remote_endpoint, ) .await } @@ -1787,6 +1941,7 @@ fn finalize_fork_interactive( fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) { let TuiCli { shared, + strict_config, approval_policy, web_search, prompt, @@ -1802,6 +1957,9 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) if web_search { interactive.web_search = true; } + if strict_config { + interactive.strict_config = true; + } if let Some(prompt) = prompt { // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); @@ -2289,6 +2447,7 @@ mod tests { "my-profile", "-C", "/tmp", + "--strict-config", "-i", "/tmp/a.png,/tmp/b.png", ] @@ -2311,6 +2470,7 @@ mod tests { Some(std::path::Path::new("/tmp")) ); assert!(interactive.web_search); + assert!(interactive.strict_config); let has_a = interactive .images .iter() @@ -2341,6 +2501,18 @@ mod tests { assert_eq!(interactive.resume_session_id, None); } + #[test] + fn resume_merges_bypass_hook_trust_flag() { + let interactive = finalize_resume_from_args( + ["codex", "resume", "--dangerously-bypass-hook-trust"].as_ref(), + ); + + assert!(interactive.bypass_hook_trust); + assert!(interactive.resume_picker); + assert!(!interactive.resume_last); + assert_eq!(interactive.resume_session_id, None); + } + #[test] fn fork_picker_logic_none_and_not_last() { let interactive = finalize_fork_from_args(["codex", "fork"].as_ref()); @@ -2392,15 +2564,81 @@ mod tests { assert!(app_server.analytics_default_enabled); } + #[test] + fn strict_config_parses_for_supported_commands() { + let cli = MultitoolCli::try_parse_from(["codex", "--strict-config"]).expect("parse"); + assert!(cli.interactive.strict_config); + + let cli = MultitoolCli::try_parse_from(["codex", "mcp-server", "--strict-config"]) + .expect("parse"); + assert_matches!( + cli.subcommand, + Some(Subcommand::McpServer(McpServerCommand { + strict_config: true, + })) + ); + + let cli = + MultitoolCli::try_parse_from(["codex", "review", "--strict-config", "--uncommitted"]) + .expect("parse"); + assert_matches!( + cli.subcommand, + Some(Subcommand::Review(ReviewCommand { + strict_config: true, + .. + })) + ); + } + + #[test] + fn root_strict_config_is_rejected_for_unsupported_subcommands() { + let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "mcp", "list"]) + .expect("parse"); + let err = reject_root_strict_config_for_subcommand( + cli.interactive.strict_config, + &cli.subcommand, + ) + .expect_err("mcp should not support root --strict-config"); + + assert_eq!( + err.to_string(), + "`--strict-config` is not supported for `codex mcp`" + ); + + let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "remote-control"]) + .expect("parse"); + let err = reject_root_strict_config_for_subcommand( + cli.interactive.strict_config, + &cli.subcommand, + ) + .expect_err("remote-control should not support root --strict-config"); + + assert_eq!( + err.to_string(), + "`--strict-config` is not supported for `codex remote-control`" + ); + } + + #[test] + fn app_server_subcommands_reject_strict_config() { + let app_server = + app_server_from_args(["codex", "app-server", "--strict-config", "proxy"].as_ref()); + let err = reject_strict_config_for_app_server_subcommand( + app_server.strict_config, + app_server.subcommand.as_ref(), + ) + .expect_err("app-server proxy should not support --strict-config"); + + assert_eq!( + err.to_string(), + "`--strict-config` is not supported for `codex app-server proxy`" + ); + } + #[test] fn reject_remote_flag_for_remote_control() { - let cli = MultitoolCli::try_parse_from([ - "codex", - "--remote", - "ws://127.0.0.1:1234", - "remote-control", - ]) - .expect("parse"); + let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://", "remote-control"]) + .expect("parse"); assert_matches!(cli.subcommand, Some(Subcommand::RemoteControl)); let err = reject_remote_mode_for_subcommand( @@ -2415,9 +2653,9 @@ mod tests { #[test] fn remote_flag_parses_for_interactive_root() { - let cli = MultitoolCli::try_parse_from(["codex", "--remote", "ws://127.0.0.1:4500"]) + let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://codex.sock"]) .expect("parse"); - assert_eq!(cli.remote.remote.as_deref(), Some("ws://127.0.0.1:4500")); + assert_eq!(cli.remote.remote.as_deref(), Some("unix://codex.sock")); } #[test] @@ -2439,14 +2677,14 @@ mod tests { #[test] fn remote_flag_parses_for_resume_subcommand() { let cli = - MultitoolCli::try_parse_from(["codex", "resume", "--remote", "ws://127.0.0.1:4500"]) + MultitoolCli::try_parse_from(["codex", "resume", "--remote", "unix://codex.sock"]) .expect("parse"); let Subcommand::Resume(ResumeCommand { remote, .. }) = cli.subcommand.expect("resume present") else { panic!("expected resume subcommand"); }; - assert_eq!(remote.remote.as_deref(), Some("ws://127.0.0.1:4500")); + assert_eq!(remote.remote.as_deref(), Some("unix://codex.sock")); } #[test] @@ -2521,14 +2759,16 @@ mod tests { } #[test] - fn app_server_listen_websocket_url_fails_to_parse() { - let parse_result = MultitoolCli::try_parse_from([ - "codex", - "app-server", - "--listen", - "ws://127.0.0.1:4500", - ]); - assert!(parse_result.is_err()); + fn app_server_listen_websocket_url_parses() { + let app_server = app_server_from_args( + ["codex", "app-server", "--listen", "ws://127.0.0.1:4500"].as_ref(), + ); + assert_eq!( + app_server.listen, + codex_app_server::AppServerTransport::WebSocket { + bind_address: "127.0.0.1:4500".parse().expect("valid socket address"), + } + ); } #[test] @@ -2697,6 +2937,61 @@ mod tests { assert!(err.to_string().contains("app-server daemon version")); } + #[test] + fn app_server_capability_token_flags_parse() { + let app_server = app_server_from_args( + [ + "codex", + "app-server", + "--ws-auth", + "capability-token", + "--ws-token-file", + "/tmp/codex-token", + ] + .as_ref(), + ); + assert_eq!( + app_server.auth.ws_auth, + Some(codex_app_server::WebsocketAuthCliMode::CapabilityToken) + ); + assert_eq!( + app_server.auth.ws_token_file, + Some(PathBuf::from("/tmp/codex-token")) + ); + } + + #[test] + fn app_server_signed_bearer_flags_parse() { + let app_server = app_server_from_args( + [ + "codex", + "app-server", + "--ws-auth", + "signed-bearer-token", + "--ws-shared-secret-file", + "/tmp/codex-secret", + "--ws-issuer", + "issuer", + "--ws-audience", + "audience", + "--ws-max-clock-skew-seconds", + "9", + ] + .as_ref(), + ); + assert_eq!( + app_server.auth.ws_auth, + Some(codex_app_server::WebsocketAuthCliMode::SignedBearerToken) + ); + assert_eq!( + app_server.auth.ws_shared_secret_file, + Some(PathBuf::from("/tmp/codex-secret")) + ); + assert_eq!(app_server.auth.ws_issuer.as_deref(), Some("issuer")); + assert_eq!(app_server.auth.ws_audience.as_deref(), Some("audience")); + assert_eq!(app_server.auth.ws_max_clock_skew_seconds, Some(9)); + } + #[test] fn app_server_rejects_removed_insecure_non_loopback_flag() { let parse_result = MultitoolCli::try_parse_from([ @@ -2786,4 +3081,38 @@ mod tests { .expect_err("feature should be rejected"); assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); } + + #[test] + fn strict_config_with_unknown_enable_errors() { + let err = strict_config_feature_toggle_error(["--enable", "does_not_exist"].as_ref()); + assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); + } + + #[test] + fn strict_config_with_unknown_disable_errors() { + let err = strict_config_feature_toggle_error(["--disable", "does_not_exist"].as_ref()); + assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); + } + + #[test] + fn strict_config_with_compound_enable_errors() { + let err = strict_config_feature_toggle_error( + ["--enable", "multi_agent_v2.subagent_usage_hint_text"].as_ref(), + ); + assert_eq!( + err.to_string(), + "Unknown feature flag: multi_agent_v2.subagent_usage_hint_text" + ); + } + + fn strict_config_feature_toggle_error(args: &[&str]) -> anyhow::Error { + let cli_args = std::iter::once("codex") + .chain(std::iter::once("--strict-config")) + .chain(args.iter().copied()); + let cli = MultitoolCli::try_parse_from(cli_args).expect("parse should succeed"); + assert!(cli.interactive.strict_config); + cli.feature_toggles + .to_overrides() + .expect_err("feature should be rejected") + } } diff --git a/codex-rs/cli/tests/app_server.rs b/codex-rs/cli/tests/app_server.rs new file mode 100644 index 0000000000..4a2642379d --- /dev/null +++ b/codex-rs/cli/tests/app_server.rs @@ -0,0 +1,30 @@ +use std::path::Path; + +use anyhow::Result; +use predicates::str::contains; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[test] +fn strict_config_rejects_unknown_config_fields_for_app_server() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +foo = "bar" +"#, + )?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["app-server", "--strict-config", "--listen", "off"]) + .assert() + .failure() + .stderr(contains("unknown configuration field")); + + Ok(()) +} diff --git a/codex-rs/cli/tests/features.rs b/codex-rs/cli/tests/features.rs index 17a7eff679..596cfaa8f8 100644 --- a/codex-rs/cli/tests/features.rs +++ b/codex-rs/cli/tests/features.rs @@ -11,6 +11,34 @@ fn codex_command(codex_home: &Path) -> Result { Ok(cmd) } +#[test] +fn strict_config_rejects_unknown_config_override() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["--strict-config", "-c", "foo=bar", "mcp-server"]) + .assert() + .failure() + .stderr(contains("unknown configuration field")); + + Ok(()) +} + +#[test] +fn strict_config_is_not_supported_for_cloud_command() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["--strict-config", "-c", "foo=bar", "cloud", "list"]) + .assert() + .failure() + .stderr(contains( + "`--strict-config` is not supported for `codex cloud`", + )); + + Ok(()) +} + #[tokio::test] async fn features_enable_writes_feature_flag_to_config() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 360e99a217..8812762cbe 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1204,6 +1204,7 @@ mod tests { allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1286,6 +1287,7 @@ mod tests { allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1319,6 +1321,7 @@ mod tests { allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1369,6 +1372,7 @@ mod tests { allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1519,6 +1523,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1599,6 +1604,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1677,6 +1683,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1883,6 +1890,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1923,6 +1931,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1983,6 +1992,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2039,6 +2049,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2097,6 +2108,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2156,6 +2168,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2215,6 +2228,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2304,6 +2318,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2335,6 +2350,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index bf0ce699cc..3da8c77325 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -23,9 +23,12 @@ pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS; pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL; pub use runtime::DEFAULT_WAIT_YIELD_TIME_MS; pub use runtime::ExecuteRequest; +pub use runtime::ExecuteToPendingOutcome; pub use runtime::RuntimeResponse; pub use runtime::WaitOutcome; pub use runtime::WaitRequest; +pub use runtime::WaitToPendingOutcome; +pub use runtime::WaitToPendingRequest; pub use service::CodeModeService; pub use service::CodeModeTurnHost; pub use service::CodeModeTurnWorker; diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode/src/runtime/callbacks.rs index a9755f6eb0..c3a648ae32 100644 --- a/codex-rs/code-mode/src/runtime/callbacks.rs +++ b/codex-rs/code-mode/src/runtime/callbacks.rs @@ -42,20 +42,16 @@ pub(super) fn tool_callback( let promise = resolver.get_promise(scope); let resolver = v8::Global::new(scope, resolver); - let tool_name = { + let (tool_name, tool_kind) = { let Some(state) = scope.get_slot::() else { throw_type_error(scope, "runtime state unavailable"); return; }; - let Some(tool_name) = state - .enabled_tools - .get(tool_index) - .map(|tool| tool.tool_name.clone()) - else { + let Some(tool) = state.enabled_tools.get(tool_index) else { throw_type_error(scope, "tool callback data is out of range"); return; }; - tool_name + (tool.tool_name.clone(), tool.kind) }; let Some(state) = scope.get_slot_mut::() else { @@ -69,6 +65,7 @@ pub(super) fn tool_callback( let _ = event_tx.send(RuntimeEvent::ToolCall { id, name: tool_name, + kind: tool_kind, input, }); retval.set(promise.into()); diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index 200a47c989..2b605fa573 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -14,6 +14,7 @@ use serde::Serialize; use serde_json::Value as JsonValue; use tokio::sync::mpsc; +use crate::description::CodeModeToolKind; use crate::description::EnabledToolMetadata; use crate::description::ToolDefinition; use crate::description::enabled_tool_metadata; @@ -46,6 +47,11 @@ pub struct WaitRequest { pub terminate: bool, } +#[derive(Clone, Debug)] +pub struct WaitToPendingRequest { + pub cell_id: String, +} + /// Result of waiting on a code-mode cell. /// /// The wrapped `RuntimeResponse` is the model-facing wait result. The enum @@ -61,6 +67,34 @@ pub enum WaitOutcome { MissingCell(RuntimeResponse), } +/// Result of executing a code-mode cell until it either completes or reaches a +/// quiescent pending state. +#[derive(Debug, PartialEq)] +pub enum ExecuteToPendingOutcome { + /// The cell is waiting for more runtime input after draining the runtime + /// input queue that was ready at the pending boundary. + Pending { + cell_id: String, + content_items: Vec, + /// Runtime tool-call ids emitted before this paused execution frontier + /// sealed. Hosts can use these ids to drain their tool-call transport + /// before surfacing the pending boundary to callers. + pending_tool_call_ids: Vec, + }, + /// The cell reached a terminal runtime response before going pending. + Completed(RuntimeResponse), +} + +/// Result of resuming a live code-mode cell until it completes or becomes +/// quiescent again. +#[derive(Debug, PartialEq)] +pub enum WaitToPendingOutcome { + /// The requested code cell was live when the wait command was accepted. + LiveCell(ExecuteToPendingOutcome), + /// The requested code cell was not live. + MissingCell(RuntimeResponse), +} + impl From for RuntimeResponse { fn from(outcome: WaitOutcome) -> Self { match outcome { @@ -97,6 +131,7 @@ pub struct CodeModeNestedToolCall { pub cell_id: String, pub runtime_tool_call_id: String, pub tool_name: ToolName, + pub tool_kind: CodeModeToolKind, pub input: Option, } @@ -118,14 +153,28 @@ pub(crate) enum RuntimeCommand { Terminate, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum PendingRuntimeMode { + Continue, + PauseUntilResumed, +} + +#[derive(Debug)] +pub(crate) enum RuntimeControlCommand { + Resume, + Terminate, +} + #[derive(Debug)] pub(crate) enum RuntimeEvent { Started, + Pending, ContentItem(FunctionCallOutputContentItem), YieldRequested, ToolCall { id: String, name: ToolName, + kind: CodeModeToolKind, input: Option, }, Notify { @@ -141,10 +190,19 @@ pub(crate) enum RuntimeEvent { pub(crate) fn spawn_runtime( request: ExecuteRequest, event_tx: mpsc::UnboundedSender, -) -> Result<(std_mpsc::Sender, v8::IsolateHandle), String> { + pending_mode: PendingRuntimeMode, +) -> Result< + ( + std_mpsc::Sender, + std_mpsc::Sender, + v8::IsolateHandle, + ), + String, +> { initialize_v8()?; let (command_tx, command_rx) = std_mpsc::channel(); + let (control_tx, control_rx) = std_mpsc::channel(); let runtime_command_tx = command_tx.clone(); let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1); let enabled_tools = request @@ -164,6 +222,8 @@ pub(crate) fn spawn_runtime( config, event_tx, command_rx, + control_rx, + pending_mode, isolate_handle_tx, runtime_command_tx, ); @@ -172,7 +232,7 @@ pub(crate) fn spawn_runtime( let isolate_handle = isolate_handle_rx .recv() .map_err(|_| "failed to initialize code mode runtime".to_string())?; - Ok((command_tx, isolate_handle)) + Ok((command_tx, control_tx, isolate_handle)) } #[derive(Clone)] @@ -224,6 +284,8 @@ fn run_runtime( config: RuntimeConfig, event_tx: mpsc::UnboundedSender, command_rx: std_mpsc::Receiver, + control_rx: std_mpsc::Receiver, + pending_mode: PendingRuntimeMode, isolate_handle_tx: std_mpsc::SyncSender, runtime_command_tx: std_mpsc::Sender, ) { @@ -279,7 +341,8 @@ fn run_runtime( let mut pending_promise = pending_promise; loop { - let Ok(command) = command_rx.recv() else { + let Some(command) = next_runtime_command(&event_tx, &command_rx, &control_rx, pending_mode) + else { break; }; @@ -330,6 +393,30 @@ fn run_runtime( } } +fn next_runtime_command( + event_tx: &mpsc::UnboundedSender, + command_rx: &std_mpsc::Receiver, + control_rx: &std_mpsc::Receiver, + pending_mode: PendingRuntimeMode, +) -> Option { + loop { + match command_rx.try_recv() { + Ok(command) => return Some(command), + Err(std_mpsc::TryRecvError::Disconnected) => return None, + Err(std_mpsc::TryRecvError::Empty) => {} + } + + let _ = event_tx.send(RuntimeEvent::Pending); + match pending_mode { + PendingRuntimeMode::Continue => return command_rx.recv().ok(), + PendingRuntimeMode::PauseUntilResumed => match control_rx.recv().ok()? { + RuntimeControlCommand::Resume => continue, + RuntimeControlCommand::Terminate => return Some(RuntimeCommand::Terminate), + }, + } + } +} + fn capture_scope_send_error( scope: &mut v8::PinScope<'_, '_>, event_tx: &mpsc::UnboundedSender, @@ -363,8 +450,12 @@ mod tests { use tokio::sync::mpsc; use super::ExecuteRequest; + use super::PendingRuntimeMode; + use super::RuntimeCommand; + use super::RuntimeControlCommand; use super::RuntimeEvent; use super::spawn_runtime; + use crate::FunctionCallOutputContentItem; fn execute_request(source: &str) -> ExecuteRequest { ExecuteRequest { @@ -381,8 +472,12 @@ mod tests { #[tokio::test] async fn terminate_execution_stops_cpu_bound_module() { let (event_tx, mut event_rx) = mpsc::unbounded_channel(); - let (_runtime_tx, runtime_terminate_handle) = - spawn_runtime(execute_request("while (true) {}"), event_tx).unwrap(); + let (_runtime_tx, _runtime_control_tx, runtime_terminate_handle) = spawn_runtime( + execute_request("while (true) {}"), + event_tx, + PendingRuntimeMode::Continue, + ) + .unwrap(); let started_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) .await @@ -413,4 +508,71 @@ mod tests { .is_none() ); } + + #[tokio::test] + async fn pending_mode_freezes_runtime_commands_until_resume() { + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let (runtime_tx, runtime_control_tx, _runtime_terminate_handle) = spawn_runtime( + execute_request( + r#" +await new Promise((resolve) => setTimeout(resolve, 60_000)); +text("after"); +await new Promise(() => {}); +"#, + ), + event_tx, + PendingRuntimeMode::PauseUntilResumed, + ) + .unwrap(); + + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(), + RuntimeEvent::Started + )); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(), + RuntimeEvent::Pending + )); + + runtime_tx + .send(RuntimeCommand::TimeoutFired { id: 1 }) + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(100), event_rx.recv()) + .await + .is_err() + ); + + runtime_control_tx + .send(RuntimeControlCommand::Resume) + .unwrap(); + + let content_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + let RuntimeEvent::ContentItem(FunctionCallOutputContentItem::InputText { text }) = + content_event + else { + panic!("expected resumed runtime output"); + }; + assert_eq!(text, "after"); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(), + RuntimeEvent::Pending + )); + + runtime_control_tx + .send(RuntimeControlCommand::Terminate) + .unwrap(); + } } diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 7326c834e2..44e4be4939 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -16,12 +16,17 @@ use crate::FunctionCallOutputContentItem; use crate::runtime::CodeModeNestedToolCall; use crate::runtime::DEFAULT_EXEC_YIELD_TIME_MS; use crate::runtime::ExecuteRequest; +use crate::runtime::ExecuteToPendingOutcome; +use crate::runtime::PendingRuntimeMode; use crate::runtime::RuntimeCommand; +use crate::runtime::RuntimeControlCommand; use crate::runtime::RuntimeEvent; use crate::runtime::RuntimeResponse; use crate::runtime::TurnMessage; use crate::runtime::WaitOutcome; use crate::runtime::WaitRequest; +use crate::runtime::WaitToPendingOutcome; +use crate::runtime::WaitToPendingRequest; use crate::runtime::spawn_runtime; #[async_trait] @@ -90,18 +95,57 @@ impl CodeModeService { } pub async fn execute(&self, request: ExecuteRequest) -> Result { - let cell_id = request.cell_id.clone(); let initial_yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); + let (response_tx, response_rx) = oneshot::channel(); + self.start_session( + request, + SessionResponseSender::Runtime(response_tx), + Some(initial_yield_time_ms), + PendingRuntimeMode::Continue, + ) + .await?; + + response_rx + .await + .map_err(|_| "exec runtime ended unexpectedly".to_string()) + } + + pub async fn execute_to_pending( + &self, + request: ExecuteRequest, + ) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + self.start_session( + request, + SessionResponseSender::ExecuteToPending(response_tx), + /*initial_yield_time_ms*/ None, + PendingRuntimeMode::PauseUntilResumed, + ) + .await?; + + response_rx + .await + .map_err(|_| "exec runtime ended unexpectedly".to_string()) + } + + async fn start_session( + &self, + request: ExecuteRequest, + initial_response_tx: SessionResponseSender, + initial_yield_time_ms: Option, + pending_mode: PendingRuntimeMode, + ) -> Result<(), String> { + let cell_id = request.cell_id.clone(); let (event_tx, event_rx) = mpsc::unbounded_channel(); let (control_tx, control_rx) = mpsc::unbounded_channel(); - let (response_tx, response_rx) = oneshot::channel(); - let (runtime_tx, runtime_terminate_handle) = { + let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = { let mut sessions = self.inner.sessions.lock().await; if sessions.contains_key(&cell_id) { return Err(format!("exec cell {cell_id} already exists")); } - let (runtime_tx, runtime_terminate_handle) = spawn_runtime(request, event_tx)?; + let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = + spawn_runtime(request, event_tx, pending_mode)?; // Keep the session registry locked through insertion so a // caller-owned cell id cannot race with another execute and replace @@ -113,7 +157,7 @@ impl CodeModeService { runtime_tx: runtime_tx.clone(), }, ); - (runtime_tx, runtime_terminate_handle) + (runtime_tx, runtime_control_tx, runtime_terminate_handle) }; tokio::spawn(run_session_control( @@ -121,17 +165,17 @@ impl CodeModeService { SessionControlContext { cell_id: cell_id.clone(), runtime_tx, + runtime_control_tx, + pending_mode, runtime_terminate_handle, }, event_rx, control_rx, - response_tx, + initial_response_tx, initial_yield_time_ms, )); - response_rx - .await - .map_err(|_| "exec runtime ended unexpectedly".to_string()) + Ok(()) } pub async fn wait(&self, request: WaitRequest) -> Result { @@ -166,6 +210,41 @@ impl CodeModeService { } } + pub async fn wait_to_pending( + &self, + request: WaitToPendingRequest, + ) -> Result { + let cell_id = request.cell_id.clone(); + let handle = self + .inner + .sessions + .lock() + .await + .get(&request.cell_id) + .cloned(); + let Some(handle) = handle else { + return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( + cell_id, + ))); + }; + let (response_tx, response_rx) = oneshot::channel(); + if handle + .control_tx + .send(SessionControlCommand::PollToPending { response_tx }) + .is_err() + { + return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( + cell_id, + ))); + } + match response_rx.await { + Ok(response) => Ok(WaitToPendingOutcome::LiveCell(response)), + Err(_) => Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( + request.cell_id, + ))), + } + } + pub fn start_turn_worker(&self, host: Arc) -> CodeModeTurnWorker { let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); let inner = Arc::clone(&self.inner); @@ -255,11 +334,19 @@ enum SessionControlCommand { yield_time_ms: u64, response_tx: oneshot::Sender, }, + PollToPending { + response_tx: oneshot::Sender, + }, Terminate { response_tx: oneshot::Sender, }, } +enum SessionResponseSender { + Runtime(oneshot::Sender), + ExecuteToPending(oneshot::Sender), +} + struct PendingResult { content_items: Vec, stored_values: HashMap, @@ -269,6 +356,8 @@ struct PendingResult { struct SessionControlContext { cell_id: String, runtime_tx: std::sync::mpsc::Sender, + runtime_control_tx: std::sync::mpsc::Sender, + pending_mode: PendingRuntimeMode, runtime_terminate_handle: v8::IsolateHandle, } @@ -290,14 +379,26 @@ fn pending_result_response(cell_id: &str, result: PendingResult) -> RuntimeRespo } } +fn send_terminal_response(response_tx: SessionResponseSender, response: RuntimeResponse) { + match response_tx { + SessionResponseSender::Runtime(response_tx) => { + let _ = response_tx.send(response); + } + SessionResponseSender::ExecuteToPending(response_tx) => { + let _ = response_tx.send(ExecuteToPendingOutcome::Completed(response)); + } + } +} + fn send_or_buffer_result( cell_id: &str, result: PendingResult, - response_tx: &mut Option>, + response_tx: &mut Option, pending_result: &mut Option, ) -> bool { if let Some(response_tx) = response_tx.take() { - let _ = response_tx.send(pending_result_response(cell_id, result)); + let response = pending_result_response(cell_id, result); + send_terminal_response(response_tx, response); return true; } @@ -305,20 +406,46 @@ fn send_or_buffer_result( false } +fn send_yield_response( + cell_id: &str, + content_items: &mut Vec, + response_tx: &mut Option, +) { + let Some(current_response_tx) = response_tx.take() else { + return; + }; + match current_response_tx { + SessionResponseSender::Runtime(response_tx) => { + let _ = response_tx.send(RuntimeResponse::Yielded { + cell_id: cell_id.to_string(), + content_items: std::mem::take(content_items), + }); + } + SessionResponseSender::ExecuteToPending(execute_to_pending_tx) => { + *response_tx = Some(SessionResponseSender::ExecuteToPending( + execute_to_pending_tx, + )); + } + } +} + async fn run_session_control( inner: Arc, context: SessionControlContext, mut event_rx: mpsc::UnboundedReceiver, mut control_rx: mpsc::UnboundedReceiver, - initial_response_tx: oneshot::Sender, - initial_yield_time_ms: u64, + initial_response_tx: SessionResponseSender, + initial_yield_time_ms: Option, ) { let SessionControlContext { cell_id, runtime_tx, + runtime_control_tx, + pending_mode, runtime_terminate_handle, } = context; let mut content_items = Vec::new(); + let mut pending_tool_call_ids = Vec::new(); let mut pending_result: Option = None; let mut response_tx = Some(initial_response_tx); let mut termination_requested = false; @@ -338,10 +465,11 @@ async fn run_session_control( runtime_closed = true; if termination_requested { if let Some(response_tx) = response_tx.take() { - let _ = response_tx.send(RuntimeResponse::Terminated { + let response = RuntimeResponse::Terminated { cell_id: cell_id.clone(), content_items: std::mem::take(&mut content_items), - }); + }; + send_terminal_response(response_tx, response); } break; } @@ -364,19 +492,35 @@ async fn run_session_control( }; match event { RuntimeEvent::Started => { - yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms)))); + yield_timer = initial_yield_time_ms.map(|initial_yield_time_ms| { + Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms))) + }); + } + RuntimeEvent::Pending => { + if let Some(current_response_tx) = response_tx.take() { + match current_response_tx { + SessionResponseSender::Runtime(runtime_response_tx) => { + response_tx = + Some(SessionResponseSender::Runtime(runtime_response_tx)); + } + SessionResponseSender::ExecuteToPending(response_tx) => { + let _ = response_tx.send(ExecuteToPendingOutcome::Pending { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + pending_tool_call_ids: std::mem::take( + &mut pending_tool_call_ids, + ), + }); + } + } + } } RuntimeEvent::ContentItem(item) => { content_items.push(item); } RuntimeEvent::YieldRequested => { yield_timer = None; - if let Some(response_tx) = response_tx.take() { - let _ = response_tx.send(RuntimeResponse::Yielded { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }); - } + send_yield_response(&cell_id, &mut content_items, &mut response_tx); } RuntimeEvent::Notify { call_id, text } => { let _ = inner.turn_message_tx.send(TurnMessage::Notify { @@ -385,11 +529,20 @@ async fn run_session_control( text, }).await; } - RuntimeEvent::ToolCall { id, name, input } => { + RuntimeEvent::ToolCall { + id, + name, + kind, + input, + } => { + if pending_mode == PendingRuntimeMode::PauseUntilResumed { + pending_tool_call_ids.push(id.clone()); + } let tool_call = CodeModeNestedToolCall { cell_id: cell_id.clone(), runtime_tool_call_id: id, tool_name: name, + tool_kind: kind, input, }; let _ = inner @@ -404,10 +557,11 @@ async fn run_session_control( yield_timer = None; if termination_requested { if let Some(response_tx) = response_tx.take() { - let _ = response_tx.send(RuntimeResponse::Terminated { + let response = RuntimeResponse::Terminated { cell_id: cell_id.clone(), content_items: std::mem::take(&mut content_items), - }); + }; + send_terminal_response(response_tx, response); } break; } @@ -440,8 +594,23 @@ async fn run_session_control( let _ = next_response_tx.send(pending_result_response(&cell_id, result)); break; } - response_tx = Some(next_response_tx); + response_tx = Some(SessionResponseSender::Runtime(next_response_tx)); yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms)))); + resume_paused_runtime(&runtime_control_tx, pending_mode); + } + SessionControlCommand::PollToPending { + response_tx: next_response_tx, + } => { + if let Some(result) = pending_result.take() { + let response = pending_result_response(&cell_id, result); + let _ = next_response_tx + .send(ExecuteToPendingOutcome::Completed(response)); + break; + } + response_tx = + Some(SessionResponseSender::ExecuteToPending(next_response_tx)); + yield_timer = None; + resume_paused_runtime(&runtime_control_tx, pending_mode); } SessionControlCommand::Terminate { response_tx: next_response_tx } => { if let Some(result) = pending_result.take() { @@ -449,17 +618,19 @@ async fn run_session_control( break; } - response_tx = Some(next_response_tx); + response_tx = Some(SessionResponseSender::Runtime(next_response_tx)); termination_requested = true; yield_timer = None; let _ = runtime_tx.send(RuntimeCommand::Terminate); + terminate_paused_runtime(&runtime_control_tx, pending_mode); let _ = runtime_terminate_handle.terminate_execution(); if runtime_closed { if let Some(response_tx) = response_tx.take() { - let _ = response_tx.send(RuntimeResponse::Terminated { + let response = RuntimeResponse::Terminated { cell_id: cell_id.clone(), content_items: std::mem::take(&mut content_items), - }); + }; + send_terminal_response(response_tx, response); } break; } else { @@ -476,20 +647,34 @@ async fn run_session_control( } } => { yield_timer = None; - if let Some(response_tx) = response_tx.take() { - let _ = response_tx.send(RuntimeResponse::Yielded { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }); - } + send_yield_response(&cell_id, &mut content_items, &mut response_tx); } } } let _ = runtime_tx.send(RuntimeCommand::Terminate); + terminate_paused_runtime(&runtime_control_tx, pending_mode); inner.sessions.lock().await.remove(&cell_id); } +fn resume_paused_runtime( + runtime_control_tx: &std::sync::mpsc::Sender, + pending_mode: PendingRuntimeMode, +) { + if pending_mode == PendingRuntimeMode::PauseUntilResumed { + let _ = runtime_control_tx.send(RuntimeControlCommand::Resume); + } +} + +fn terminate_paused_runtime( + runtime_control_tx: &std::sync::mpsc::Sender, + pending_mode: PendingRuntimeMode, +) { + if pending_mode == PendingRuntimeMode::PauseUntilResumed { + let _ = runtime_control_tx.send(RuntimeControlCommand::Terminate); + } +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -497,6 +682,7 @@ mod tests { use std::sync::atomic::AtomicU64; use std::time::Duration; + use codex_protocol::ToolName; use pretty_assertions::assert_eq; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -504,15 +690,22 @@ mod tests { use super::CodeModeService; use super::Inner; + use super::PendingRuntimeMode; use super::RuntimeCommand; use super::RuntimeResponse; use super::SessionControlCommand; use super::SessionControlContext; + use super::SessionResponseSender; use super::WaitOutcome; use super::WaitRequest; + use super::WaitToPendingOutcome; + use super::WaitToPendingRequest; use super::run_session_control; + use crate::CodeModeToolKind; use crate::FunctionCallOutputContentItem; + use crate::ToolDefinition; use crate::runtime::ExecuteRequest; + use crate::runtime::ExecuteToPendingOutcome; use crate::runtime::RuntimeEvent; use crate::runtime::spawn_runtime; @@ -565,6 +758,363 @@ mod tests { ); } + #[tokio::test] + async fn execute_to_pending_returns_completed_for_synchronous_results() { + let service = CodeModeService::new(); + + let response = service + .execute_to_pending(ExecuteRequest { + source: r#"text("done");"#.to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + ExecuteToPendingOutcome::Completed(RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + }) + ); + } + + #[tokio::test] + async fn execute_to_pending_returns_once_the_runtime_is_quiescent() { + let service = CodeModeService::new(); + + let response = tokio::time::timeout( + Duration::from_secs(1), + service.execute_to_pending(ExecuteRequest { + source: r#"text("before"); await new Promise(() => {});"#.to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + response, + ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "before".to_string(), + }], + pending_tool_call_ids: Vec::new(), + } + ); + + let termination = service + .wait(WaitRequest { + cell_id: "1".to_string(), + yield_time_ms: 1, + terminate: true, + }) + .await + .unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: "1".to_string(), + content_items: Vec::new(), + }) + ); + } + + #[tokio::test] + async fn execute_to_pending_identifies_tool_calls_in_paused_frontier() { + let service = CodeModeService::new(); + + let response = service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![ToolDefinition { + name: "echo".to_string(), + tool_name: ToolName::plain("echo"), + description: String::new(), + kind: CodeModeToolKind::Function, + input_schema: None, + output_schema: None, + }], + source: r#" +await Promise.all([ + tools.echo({ value: "first" }), + tools.echo({ value: "second" }), +]); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], + } + ); + + let termination = service + .wait(WaitRequest { + cell_id: "1".to_string(), + yield_time_ms: 1, + terminate: true, + }) + .await + .unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: "1".to_string(), + content_items: Vec::new(), + }) + ); + } + + #[tokio::test] + async fn execute_to_pending_excludes_delayed_timeout_tool_calls_until_wait() { + let service = CodeModeService::new(); + + let initial_response = service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![ToolDefinition { + name: "echo".to_string(), + tool_name: ToolName::plain("echo"), + description: String::new(), + kind: CodeModeToolKind::Function, + input_schema: None, + output_schema: None, + }], + source: r#" +setTimeout(() => { + tools.echo({ value: "delayed" }); +}, 1000); +await Promise.all([ + tools.echo({ value: "second" }), + tools.echo({ value: "third" }), +]); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + initial_response, + ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], + } + ); + + let runtime_tx = service + .inner + .sessions + .lock() + .await + .get("1") + .unwrap() + .runtime_tx + .clone(); + runtime_tx + .send(RuntimeCommand::TimeoutFired { id: 1 }) + .unwrap(); + + let resumed_response = tokio::time::timeout( + Duration::from_secs(1), + service.wait_to_pending(WaitToPendingRequest { + cell_id: "1".to_string(), + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + resumed_response, + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-3".to_string()], + }) + ); + + let termination = service + .wait(WaitRequest { + cell_id: "1".to_string(), + yield_time_ms: 1, + terminate: true, + }) + .await + .unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: "1".to_string(), + content_items: Vec::new(), + }) + ); + } + + #[tokio::test] + async fn wait_to_pending_returns_after_resumed_runtime_becomes_quiescent_again() { + let service = CodeModeService::new(); + + let initial_response = service + .execute_to_pending(ExecuteRequest { + source: r#" +await new Promise((resolve) => setTimeout(resolve, 60_000)); +text("after"); +await new Promise(() => {}); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + initial_response, + ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: Vec::new(), + pending_tool_call_ids: Vec::new(), + } + ); + + let runtime_tx = service + .inner + .sessions + .lock() + .await + .get("1") + .unwrap() + .runtime_tx + .clone(); + runtime_tx + .send(RuntimeCommand::TimeoutFired { id: 1 }) + .unwrap(); + + let resumed_response = tokio::time::timeout( + Duration::from_secs(1), + service.wait_to_pending(WaitToPendingRequest { + cell_id: "1".to_string(), + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + resumed_response, + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "after".to_string(), + }], + pending_tool_call_ids: Vec::new(), + }) + ); + + let termination = service + .wait(WaitRequest { + cell_id: "1".to_string(), + yield_time_ms: 1, + terminate: true, + }) + .await + .unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: "1".to_string(), + content_items: Vec::new(), + }) + ); + } + + #[tokio::test] + async fn wait_to_pending_returns_completed_after_resumed_runtime_finishes() { + let service = CodeModeService::new(); + + let initial_response = service + .execute_to_pending(ExecuteRequest { + source: r#" +await new Promise((resolve) => setTimeout(resolve, 60_000)); +text("done"); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + initial_response, + ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: Vec::new(), + pending_tool_call_ids: Vec::new(), + } + ); + + let runtime_tx = service + .inner + .sessions + .lock() + .await + .get("1") + .unwrap() + .runtime_tx + .clone(); + runtime_tx + .send(RuntimeCommand::TimeoutFired { id: 1 }) + .unwrap(); + + let resumed_response = tokio::time::timeout( + Duration::from_secs(1), + service.wait_to_pending(WaitToPendingRequest { + cell_id: "1".to_string(), + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + resumed_response, + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed( + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + } + )) + ); + } + #[tokio::test] async fn v8_console_is_not_exposed_on_global_this() { let service = CodeModeService::new(); @@ -892,13 +1442,14 @@ image({ let (control_tx, control_rx) = mpsc::unbounded_channel(); let (initial_response_tx, initial_response_rx) = oneshot::channel(); let (runtime_event_tx, _runtime_event_rx) = mpsc::unbounded_channel(); - let (runtime_tx, runtime_terminate_handle) = spawn_runtime( + let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( ExecuteRequest { source: "await new Promise(() => {})".to_string(), yield_time_ms: None, ..execute_request("") }, runtime_event_tx, + PendingRuntimeMode::Continue, ) .unwrap(); @@ -907,12 +1458,14 @@ image({ SessionControlContext { cell_id: "cell-1".to_string(), runtime_tx: runtime_tx.clone(), + runtime_control_tx, + pending_mode: PendingRuntimeMode::Continue, runtime_terminate_handle, }, event_rx, control_rx, - initial_response_tx, - /*initial_yield_time_ms*/ 60_000, + SessionResponseSender::Runtime(initial_response_tx), + Some(/*initial_yield_time_ms*/ 60_000), )); event_tx.send(RuntimeEvent::Started).unwrap(); diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index e6f097db38..d7017c8d3f 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -63,7 +63,6 @@ pub use crate::provider::Provider; pub use crate::provider::RetryConfig; pub use crate::provider::is_azure_responses_provider; pub use crate::requests::Compression; -pub use crate::sse::stream_from_fixture; pub use crate::telemetry::SseTelemetry; pub use crate::telemetry::WebsocketTelemetry; pub use codex_protocol::protocol::RealtimeAudioFrame; diff --git a/codex-rs/codex-api/src/requests/headers.rs b/codex-rs/codex-api/src/requests/headers.rs index ed5a5ad647..c5cede8ac6 100644 --- a/codex-rs/codex-api/src/requests/headers.rs +++ b/codex-rs/codex-api/src/requests/headers.rs @@ -5,11 +5,9 @@ use http::HeaderValue; pub fn build_session_headers(session_id: Option, thread_id: Option) -> HeaderMap { let mut headers = HeaderMap::new(); if let Some(id) = session_id { - insert_header(&mut headers, "session_id", &id); insert_header(&mut headers, "session-id", &id); } if let Some(id) = thread_id { - insert_header(&mut headers, "thread_id", &id); insert_header(&mut headers, "thread-id", &id); } headers diff --git a/codex-rs/codex-api/src/sse/mod.rs b/codex-rs/codex-api/src/sse/mod.rs index 06b9855890..441078bdcb 100644 --- a/codex-rs/codex-api/src/sse/mod.rs +++ b/codex-rs/codex-api/src/sse/mod.rs @@ -3,4 +3,3 @@ pub(crate) mod responses; pub(crate) use responses::ResponsesStreamEvent; pub(crate) use responses::process_responses_event; pub use responses::spawn_response_stream; -pub use responses::stream_from_fixture; diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 4e949e43ae..c9f35e5a4e 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -5,24 +5,19 @@ use crate::rate_limits::parse_all_rate_limits; use crate::telemetry::SseTelemetry; use codex_client::ByteStream; use codex_client::StreamResponse; -use codex_client::TransportError; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::TokenUsage; use eventsource_stream::Eventsource; use futures::StreamExt; -use futures::TryStreamExt; use serde::Deserialize; use serde_json::Value; -use std::io::BufRead; -use std::path::Path; use std::sync::Arc; use std::sync::OnceLock; use std::time::Duration; use tokio::sync::mpsc; use tokio::time::Instant; use tokio::time::timeout; -use tokio_util::io::ReaderStream; use tracing::debug; use tracing::trace; @@ -31,35 +26,6 @@ const OPENAI_MODEL_HEADER: &str = "openai-model"; const REQUEST_ID_HEADER: &str = "x-request-id"; const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber"; -/// Streams SSE events from an on-disk fixture for tests. -pub fn stream_from_fixture( - path: impl AsRef, - idle_timeout: Duration, -) -> Result { - let file = - std::fs::File::open(path.as_ref()).map_err(|err| ApiError::Stream(err.to_string()))?; - let mut content = String::new(); - for line in std::io::BufReader::new(file).lines() { - let line = line.map_err(|err| ApiError::Stream(err.to_string()))?; - content.push_str(&line); - content.push_str("\n\n"); - } - - let reader = std::io::Cursor::new(content); - let stream = ReaderStream::new(reader).map_err(|err| TransportError::Network(err.to_string())); - let (tx_event, rx_event) = mpsc::channel::>(1600); - tokio::spawn(process_sse( - Box::pin(stream), - tx_event, - idle_timeout, - /*telemetry*/ None, - )); - Ok(ResponseStream { - rx_event, - upstream_request_id: None, - }) -} - pub fn spawn_response_stream( stream_response: StreamResponse, idle_timeout: Duration, @@ -593,8 +559,10 @@ mod tests { use assert_matches::assert_matches; use bytes::Bytes; use codex_client::StreamResponse; + use codex_client::TransportError; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; + use futures::TryStreamExt; use futures::stream; use http::HeaderMap; use http::HeaderValue; @@ -603,6 +571,7 @@ mod tests { use serde_json::json; use tokio::sync::mpsc; use tokio_test::io::Builder as IoBuilder; + use tokio_util::io::ReaderStream; async fn collect_events(chunks: &[&[u8]]) -> Vec> { let mut builder = IoBuilder::new(); diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index a78073b302..afaf82389f 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -458,18 +458,10 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { assert_eq!(requests.len(), 1); let req = &requests[0]; - assert_eq!( - req.headers.get("session_id").and_then(|v| v.to_str().ok()), - Some("sess_123") - ); assert_eq!( req.headers.get("session-id").and_then(|v| v.to_str().ok()), Some("sess_123") ); - assert_eq!( - req.headers.get("thread_id").and_then(|v| v.to_str().ok()), - Some("thread_123") - ); assert_eq!( req.headers.get("thread-id").and_then(|v| v.to_str().ok()), Some("thread_123") diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 9583a57c62..c345f31c6e 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -32,6 +32,7 @@ multimap = { workspace = true } prost = "0.14.3" schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_ignored = { workspace = true } serde_json = { workspace = true } serde_path_to_error = { workspace = true } sha2 = { workspace = true } diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 8bf3148ba8..9faa846137 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -87,6 +87,7 @@ pub struct ConfigRequirements { pub approvals_reviewer: ConstrainedWithSource, pub permission_profile: ConstrainedWithSource, pub web_search_mode: ConstrainedWithSource, + pub allow_managed_hooks_only: Option>, pub feature_requirements: Option>, pub managed_hooks: Option>, pub mcp_servers: Option>>, @@ -120,6 +121,7 @@ impl Default for ConfigRequirements { Constrained::allow_any(WebSearchMode::Cached), /*source*/ None, ), + allow_managed_hooks_only: None, feature_requirements: None, managed_hooks: None, mcp_servers: None, @@ -688,6 +690,7 @@ pub struct ConfigRequirementsToml { pub allowed_sandbox_modes: Option>, pub remote_sandbox_config: Option>, pub allowed_web_search_modes: Option>, + pub allow_managed_hooks_only: Option, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, pub hooks: Option, @@ -736,6 +739,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, pub allowed_web_search_modes: Option>>, + pub allow_managed_hooks_only: Option>, pub feature_requirements: Option>, pub hooks: Option>, pub mcp_servers: Option>>, @@ -772,6 +776,7 @@ impl ConfigRequirementsWithSources { allowed_sandbox_modes: _, remote_sandbox_config: _, allowed_web_search_modes: _, + allow_managed_hooks_only: _, feature_requirements: _, hooks: _, mcp_servers: _, @@ -801,6 +806,7 @@ impl ConfigRequirementsWithSources { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, hooks, mcp_servers, @@ -828,6 +834,7 @@ impl ConfigRequirementsWithSources { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, hooks, mcp_servers, @@ -845,6 +852,7 @@ impl ConfigRequirementsWithSources { allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), + allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), hooks: hooks.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), @@ -928,6 +936,7 @@ impl ConfigRequirementsToml { && self.allowed_sandbox_modes.is_none() && self.remote_sandbox_config.is_none() && self.allowed_web_search_modes.is_none() + && self.allow_managed_hooks_only.is_none() && self .feature_requirements .as_ref() @@ -965,6 +974,7 @@ impl TryFrom for ConfigRequirements { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, hooks, mcp_servers, @@ -1200,6 +1210,7 @@ impl TryFrom for ConfigRequirements { approvals_reviewer, permission_profile, web_search_mode, + allow_managed_hooks_only, feature_requirements, managed_hooks, mcp_servers, @@ -1272,6 +1283,7 @@ mod tests { allowed_sandbox_modes, remote_sandbox_config: _, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, hooks, mcp_servers, @@ -1292,6 +1304,8 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_web_search_modes: allowed_web_search_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_managed_hooks_only: allow_managed_hooks_only + .map(|value| Sourced::new(value, RequirementSource::Unknown)), feature_requirements: feature_requirements .map(|value| Sourced::new(value, RequirementSource::Unknown)), hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1308,6 +1322,32 @@ mod tests { } } + #[test] + fn deserialize_allow_managed_hooks_only() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_managed_hooks_only = true + "#, + )?; + + assert_eq!(requirements.allow_managed_hooks_only, Some(true)); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn allow_managed_hooks_only_false_is_still_configured() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_managed_hooks_only = false + "#, + )?; + + assert_eq!(requirements.allow_managed_hooks_only, Some(false)); + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn merge_unset_fields_copies_every_field_and_sets_sources() { let mut target = ConfigRequirementsWithSources::default(); @@ -1339,6 +1379,7 @@ mod tests { allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), + allow_managed_hooks_only: Some(true), feature_requirements: Some(feature_requirements.clone()), hooks: None, mcp_servers: None, @@ -1369,6 +1410,10 @@ mod tests { allowed_web_search_modes, enforce_source.clone(), )), + allow_managed_hooks_only: Some(Sourced::new( + /*value*/ true, + enforce_source.clone(), + )), feature_requirements: Some(Sourced::new( feature_requirements, enforce_source.clone(), @@ -1411,6 +1456,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1458,6 +1504,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 989aab1691..06993e046a 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -162,6 +162,9 @@ pub struct ConfigToml { /// Whether to inject the `` developer block. pub include_apps_instructions: Option, + /// Whether to inject the `` developer block. + pub include_collaboration_mode_instructions: Option, + /// Whether to inject the `` user block. pub include_environment_context: Option, diff --git a/codex-rs/config/src/diagnostics.rs b/codex-rs/config/src/diagnostics.rs index 899114d6d7..69549dc207 100644 --- a/codex-rs/config/src/diagnostics.rs +++ b/codex-rs/config/src/diagnostics.rs @@ -86,6 +86,23 @@ impl std::error::Error for ConfigLoadError { } } +#[derive(Clone, Copy)] +pub(crate) enum ConfigDiagnosticSource<'a> { + Path(&'a Path), + #[cfg(any(target_os = "macos", test))] + DisplayName(&'a str), +} + +impl ConfigDiagnosticSource<'_> { + pub(crate) fn to_path_buf(self) -> PathBuf { + match self { + ConfigDiagnosticSource::Path(path) => path.to_path_buf(), + #[cfg(any(target_os = "macos", test))] + ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name), + } + } +} + pub fn io_error_from_config_error( kind: io::ErrorKind, error: ConfigError, @@ -98,21 +115,39 @@ pub fn config_error_from_toml( path: impl AsRef, contents: &str, err: toml::de::Error, +) -> ConfigError { + config_error_from_toml_for_source(ConfigDiagnosticSource::Path(path.as_ref()), contents, err) +} + +pub(crate) fn config_error_from_toml_for_source( + source: ConfigDiagnosticSource<'_>, + contents: &str, + err: toml::de::Error, ) -> ConfigError { let range = err .span() .map(|span| text_range_from_span(contents, span)) .unwrap_or_else(default_range); - ConfigError::new(path.as_ref().to_path_buf(), range, err.message()) + ConfigError::new(source.to_path_buf(), range, err.message()) } pub fn config_error_from_typed_toml( path: impl AsRef, contents: &str, +) -> Option { + config_error_from_typed_toml_for_source::( + ConfigDiagnosticSource::Path(path.as_ref()), + contents, + ) +} + +fn config_error_from_typed_toml_for_source( + source: ConfigDiagnosticSource<'_>, + contents: &str, ) -> Option { let deserializer = match toml::de::Deserializer::parse(contents) { Ok(deserializer) => deserializer, - Err(err) => return Some(config_error_from_toml(path, contents, err)), + Err(err) => return Some(config_error_from_toml_for_source(source, contents, err)), }; let result: Result = serde_path_to_error::deserialize(deserializer); @@ -126,7 +161,7 @@ pub fn config_error_from_typed_toml( .map(|span| text_range_from_span(contents, span)) .unwrap_or_else(default_range); Some(ConfigError::new( - path.as_ref().to_path_buf(), + source.to_path_buf(), range, toml_err.message(), )) @@ -205,7 +240,7 @@ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Op } } -fn text_range_from_span(contents: &str, span: std::ops::Range) -> TextRange { +pub(crate) fn text_range_from_span(contents: &str, span: std::ops::Range) -> TextRange { let start = position_for_offset(contents, span.start); let end_index = if span.end > span.start { span.end - 1 @@ -290,7 +325,7 @@ fn position_for_offset(contents: &str, index: usize) -> TextPosition { } } -fn default_range() -> TextRange { +pub(crate) fn default_range() -> TextRange { let position = TextPosition { line: 1, column: 1 }; TextRange { start: position, @@ -314,7 +349,10 @@ fn span_for_path(contents: &str, path: &SerdePath) -> Option Option> { +pub(crate) fn span_for_config_path( + contents: &str, + path: &SerdePath, +) -> Option> { if is_features_table_path(path) && let Some(span) = span_for_features_value(contents) { @@ -323,6 +361,48 @@ fn span_for_config_path(contents: &str, path: &SerdePath) -> Option Option> { + let doc = contents.parse::>().ok()?; + let mut node = TomlNode::Item(doc.as_item()); + for (index, segment) in path.iter().enumerate() { + if index + 1 == path.len() { + let key_span = match &node { + TomlNode::Item(item) => item + .as_table_like() + .and_then(|table| table.get_key_value(segment)) + .and_then(|(key, _)| key.span()), + TomlNode::Table(table) => { + table.get_key_value(segment).and_then(|(key, _)| key.span()) + } + TomlNode::Value(Value::InlineTable(table)) => { + table.get_key_value(segment).and_then(|(key, _)| key.span()) + } + _ => None, + }; + if key_span.is_some() { + return key_span; + } + } + + if let Some(next) = map_child(&node, segment) { + node = next; + continue; + } + + let index = segment.parse::().ok()?; + node = seq_child(&node, index)?; + } + + match node { + TomlNode::Item(item) => item.span(), + TomlNode::Table(table) => table.span(), + TomlNode::Value(value) => value.span(), + } +} + fn is_features_table_path(path: &SerdePath) -> bool { let mut segments = path.iter(); matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features") diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 6e7e108978..732370a526 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -21,6 +21,7 @@ mod requirements_exec_policy; pub mod schema; mod skills_config; mod state; +mod strict_config; mod thread_config; mod tui_keymap; pub mod types; @@ -117,7 +118,9 @@ pub use skills_config::SkillsConfig; pub use state::ConfigLayerEntry; pub use state::ConfigLayerStack; pub use state::ConfigLayerStackOrdering; +pub use state::ConfigLoadOptions; pub use state::LoaderOverrides; +pub use strict_config::config_error_from_ignored_toml_fields; pub use thread_config::NoopThreadConfigLoader; pub use thread_config::RemoteThreadConfigLoader; pub use thread_config::SessionThreadConfig; diff --git a/codex-rs/config/src/loader/README.md b/codex-rs/config/src/loader/README.md index 28750c4929..e004cea87c 100644 --- a/codex-rs/config/src/loader/README.md +++ b/codex-rs/config/src/loader/README.md @@ -10,13 +10,14 @@ This module is the canonical place to **load and describe Codex configuration la Exported from `codex_config::loader`: -- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader) -> ConfigLayerStack` +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, cloud_requirements, thread_config_loader) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` - `layers_high_to_low() -> Vec` - `with_user_config(user_config) -> ConfigLayerStack` - `ConfigLayerEntry` (one layer’s `{name, config, version, disabled_reason}`; `name` carries source metadata) +- `ConfigLoadOptions` (user-facing load behavior such as strict config validation) - `LoaderOverrides` (test/override hooks for managed config sources) - `merge_toml_values(base, overlay)` (public helper used elsewhere) diff --git a/codex-rs/config/src/loader/layer_io.rs b/codex-rs/config/src/loader/layer_io.rs index 9c15df7271..415d82e405 100644 --- a/codex-rs/config/src/loader/layer_io.rs +++ b/codex-rs/config/src/loader/layer_io.rs @@ -2,11 +2,14 @@ use super::macos::ManagedAdminConfigLayer; #[cfg(target_os = "macos")] use super::macos::load_managed_admin_config_layer; +use crate::config_toml::ConfigToml; use crate::diagnostics::config_error_from_toml; use crate::diagnostics::io_error_from_config_error; use crate::state::LoaderOverrides; +use crate::strict_config::config_error_from_ignored_toml_value_fields; use codex_file_system::ExecutorFileSystem; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; use std::io; use std::path::Path; use std::path::PathBuf; @@ -39,6 +42,7 @@ pub(super) async fn load_config_layers_internal( fs: &dyn ExecutorFileSystem, codex_home: &Path, overrides: LoaderOverrides, + strict_config: bool, ) -> io::Result { #[cfg(target_os = "macos")] let LoaderOverrides { @@ -57,19 +61,26 @@ pub(super) async fn load_config_layers_internal( managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home)), )?; - let managed_config = - read_config_from_path(fs, &managed_config_path, /*log_missing_as_info*/ false) - .await? - .map(|managed_config| MangedConfigFromFile { - managed_config, - file: managed_config_path.clone(), - }); + let managed_config = read_config_from_path( + fs, + &managed_config_path, + /*log_missing_as_info*/ false, + strict_config, + ) + .await? + .map(|loaded| MangedConfigFromFile { + managed_config: loaded, + file: managed_config_path.clone(), + }); #[cfg(target_os = "macos")] - let managed_preferences = - load_managed_admin_config_layer(managed_preferences_base64.as_deref()) - .await? - .map(map_managed_admin_layer); + let managed_preferences = load_managed_admin_config_layer( + managed_preferences_base64.as_deref(), + strict_config, + codex_home, + ) + .await? + .map(map_managed_admin_layer); #[cfg(not(target_os = "macos"))] let managed_preferences = None; @@ -93,10 +104,16 @@ pub(super) async fn read_config_from_path( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf, log_missing_as_info: bool, + strict_config: bool, ) -> io::Result> { match fs.read_file_text(path, /*sandbox*/ None).await { Ok(contents) => match toml::from_str::(&contents) { - Ok(value) => Ok(Some(value)), + Ok(value) => { + if strict_config { + validate_config_toml_strictly(path, &contents, &value)?; + } + Ok(Some(value)) + } Err(err) => { tracing::error!("Failed to parse {}: {err}", path.as_path().display()); let config_error = config_error_from_toml(path.as_path(), &contents, err.clone()); @@ -122,6 +139,33 @@ pub(super) async fn read_config_from_path( } } +fn validate_config_toml_strictly( + path: &AbsolutePathBuf, + contents: &str, + value: &TomlValue, +) -> io::Result<()> { + let Some(base_dir) = path.as_path().parent() else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Config file {} has no parent directory", path.display()), + )); + }; + let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(config_error) = config_error_from_ignored_toml_value_fields::( + path.as_path(), + contents, + value.clone(), + ) { + return Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + /*source*/ None, + )); + } + + Ok(()) +} + /// Return the default managed config path. pub(super) fn managed_config_default_path(codex_home: &Path) -> PathBuf { #[cfg(unix)] diff --git a/codex-rs/config/src/loader/macos.rs b/codex-rs/config/src/loader/macos.rs index 3a9fc3a0ea..199e7f6daa 100644 --- a/codex-rs/config/src/loader/macos.rs +++ b/codex-rs/config/src/loader/macos.rs @@ -2,13 +2,20 @@ use super::merge_requirements_with_remote_sandbox_config; use crate::config_requirements::ConfigRequirementsToml; use crate::config_requirements::ConfigRequirementsWithSources; use crate::config_requirements::RequirementSource; +use crate::config_toml::ConfigToml; +use crate::diagnostics::ConfigDiagnosticSource; +use crate::diagnostics::config_error_from_toml_for_source; +use crate::diagnostics::io_error_from_config_error; +use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name; use base64::Engine; use base64::prelude::BASE64_STANDARD; +use codex_utils_absolute_path::AbsolutePathBufGuard; use core_foundation::base::TCFType; use core_foundation::string::CFString; use core_foundation::string::CFStringRef; use std::ffi::c_void; use std::io; +use std::path::Path; use tokio::task; use toml::Value as TomlValue; @@ -31,17 +38,20 @@ pub(super) fn managed_preferences_requirements_source() -> RequirementSource { pub(crate) async fn load_managed_admin_config_layer( override_base64: Option<&str>, + strict_config: bool, + base_dir: &Path, ) -> io::Result> { if let Some(encoded) = override_base64 { let trimmed = encoded.trim(); return if trimmed.is_empty() { Ok(None) } else { - parse_managed_config_base64(trimmed).map(Some) + parse_managed_config_base64(trimmed, strict_config, base_dir).map(Some) }; } - match task::spawn_blocking(load_managed_admin_config).await { + let base_dir = base_dir.to_path_buf(); + match task::spawn_blocking(move || load_managed_admin_config(strict_config, &base_dir)).await { Ok(result) => result, Err(join_err) => { if join_err.is_cancelled() { @@ -54,11 +64,14 @@ pub(crate) async fn load_managed_admin_config_layer( } } -fn load_managed_admin_config() -> io::Result> { +fn load_managed_admin_config( + strict_config: bool, + base_dir: &Path, +) -> io::Result> { load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)? .as_deref() .map(str::trim) - .map(parse_managed_config_base64) + .map(|encoded| parse_managed_config_base64(encoded, strict_config, base_dir)) .transpose() } @@ -134,24 +147,73 @@ fn load_managed_preference(key_name: &str) -> io::Result> { Ok(Some(value)) } -fn parse_managed_config_base64(encoded: &str) -> io::Result { +fn parse_managed_config_base64( + encoded: &str, + strict_config: bool, + base_dir: &Path, +) -> io::Result { let raw_toml = decode_managed_preferences_base64(encoded)?; - match toml::from_str::(&raw_toml) { - Ok(TomlValue::Table(parsed)) => Ok(ManagedAdminConfigLayer { + let source_name = + format!("{MANAGED_PREFERENCES_APPLICATION_ID}:{MANAGED_PREFERENCES_CONFIG_KEY}"); + let parsed = toml::from_str::(&raw_toml).map_err(|err| { + tracing::error!("Failed to parse managed config TOML: {err}"); + if strict_config { + let config_error = config_error_from_toml_for_source( + ConfigDiagnosticSource::DisplayName(&source_name), + &raw_toml, + err.clone(), + ); + io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) + } else { + io::Error::new(io::ErrorKind::InvalidData, err) + } + })?; + + validate_managed_config_toml_strictly_if_requested( + strict_config, + &source_name, + &raw_toml, + &parsed, + base_dir, + )?; + match parsed { + TomlValue::Table(parsed) => Ok(ManagedAdminConfigLayer { config: TomlValue::Table(parsed), raw_toml, }), - Ok(other) => { + other => { tracing::error!("Managed config TOML must have a table at the root, found {other:?}",); Err(io::Error::new( io::ErrorKind::InvalidData, "managed config root must be a table", )) } - Err(err) => { - tracing::error!("Failed to parse managed config TOML: {err}"); - Err(io::Error::new(io::ErrorKind::InvalidData, err)) - } + } +} + +fn validate_managed_config_toml_strictly_if_requested( + strict_config: bool, + source_name: &str, + raw_toml: &str, + parsed: &TomlValue, + base_dir: &Path, +) -> io::Result<()> { + if !strict_config { + return Ok(()); + } + + let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::< + ConfigToml, + >(source_name, raw_toml, parsed.clone()) + { + Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + /*source*/ None, + )) + } else { + Ok(()) } } diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index e9f819bcf9..1bf70c4d05 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -21,7 +21,11 @@ use crate::project_root_markers::default_project_root_markers; use crate::project_root_markers::project_root_markers_from_config; use crate::state::ConfigLayerEntry; use crate::state::ConfigLayerStack; +use crate::state::ConfigLoadOptions; use crate::state::LoaderOverrides; +use crate::strict_config::config_error_from_ignored_toml_value_fields; +use crate::strict_config::ignored_toml_value_field; +use crate::strict_config::unknown_feature_toml_value_field; use crate::thread_config::ThreadConfigContext; use crate::thread_config::ThreadConfigLoader; use codex_app_server_protocol::ConfigLayerSource; @@ -104,10 +108,14 @@ pub async fn load_config_layers_state( codex_home: &Path, cwd: Option, cli_overrides: &[(String, TomlValue)], - overrides: LoaderOverrides, + options: impl Into, cloud_requirements: CloudRequirementsLoader, thread_config_loader: &dyn ThreadConfigLoader, ) -> io::Result { + let ConfigLoadOptions { + loader_overrides: overrides, + strict_config, + } = options.into(); let ignore_managed_requirements = overrides.ignore_managed_requirements; let ignore_user_config = overrides.ignore_user_config; let ignore_user_and_project_exec_policy_rules = @@ -140,7 +148,8 @@ pub async fn load_config_layers_state( // Make a best-effort to support the legacy `managed_config.toml` as a // requirements specification. let loaded_config_layers = - layer_io::load_config_layers_internal(fs, codex_home, overrides.clone()).await?; + layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config) + .await?; if !ignore_managed_requirements { load_requirements_from_legacy_scheme( &mut config_requirements_toml, @@ -168,6 +177,9 @@ pub async fn load_config_layers_state( .as_ref() .map(AbsolutePathBuf::as_path) .unwrap_or(codex_home); + if strict_config { + validate_cli_overrides_strictly(&cli_overrides_layer, base_dir)?; + } Some(resolve_relative_paths_in_config_toml( cli_overrides_layer, base_dir, @@ -177,16 +189,20 @@ pub async fn load_config_layers_state( // Include an entry for the "system" config folder, loading its config.toml, // if it exists. let system_config_toml_file = system_config_toml_file_with_overrides(&overrides)?; - let system_layer = - load_config_toml_for_required_layer(fs, &system_config_toml_file, |config_toml| { + let system_layer = load_config_toml_for_required_layer( + fs, + &system_config_toml_file, + strict_config, + |config_toml| { ConfigLayerEntry::new( ConfigLayerSource::System { file: system_config_toml_file.clone(), }, config_toml, ) - }) - .await?; + }, + ) + .await?; layers.push(system_layer); // Add a layer for $CODEX_HOME/config.toml so folder-derived resources such @@ -201,7 +217,7 @@ pub async fn load_config_layers_state( TomlValue::Table(toml::map::Map::new()), ) } else { - load_config_toml_for_required_layer(fs, &user_file, |config_toml| { + load_config_toml_for_required_layer(fs, &user_file, strict_config, |config_toml| { ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file.clone(), @@ -268,6 +284,7 @@ pub async fn load_config_layers_state( &project_trust_context.project_root, &project_trust_context, codex_home, + strict_config, ) .await?; layers.extend(project_layers.layers); @@ -359,15 +376,11 @@ fn insert_layer_by_precedence(layers: &mut Vec, layer: ConfigL async fn load_config_toml_for_required_layer( fs: &dyn ExecutorFileSystem, toml_file: &AbsolutePathBuf, + strict_config: bool, create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry, ) -> io::Result { let toml_value = match fs.read_file_text(toml_file, /*sandbox*/ None).await { Ok(contents) => { - let config: TomlValue = toml::from_str(&contents).map_err(|err| { - let config_error = - config_error_from_toml(toml_file.as_path(), &contents, err.clone()); - io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) - })?; let config_parent = toml_file.as_path().parent().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, @@ -377,6 +390,19 @@ async fn load_config_toml_for_required_layer( ), ) })?; + let config: TomlValue = toml::from_str(&contents).map_err(|err| { + let config_error = + config_error_from_toml(toml_file.as_path(), &contents, err.clone()); + io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) + })?; + if strict_config { + validate_config_toml_strictly( + toml_file.as_path(), + &contents, + &config, + config_parent, + )?; + } resolve_relative_paths_in_config_toml(config, config_parent) } Err(e) => { @@ -397,6 +423,51 @@ async fn load_config_toml_for_required_layer( Ok(create_entry(toml_value)) } +fn validate_config_toml_strictly( + toml_file: &Path, + contents: &str, + value: &TomlValue, + base_dir: &Path, +) -> io::Result<()> { + let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(config_error) = config_error_from_ignored_toml_value_fields::( + toml_file, + contents, + value.clone(), + ) { + Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + /*source*/ None, + )) + } else { + Ok(()) + } +} + +fn validate_cli_overrides_strictly( + cli_overrides_layer: &TomlValue, + base_dir: &Path, +) -> io::Result<()> { + let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(ignored_path) = ignored_toml_value_field::(cli_overrides_layer.clone()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unknown configuration field `{ignored_path}` in -c/--config override"), + )); + } + + if let Some(ignored_path) = unknown_feature_toml_value_field(cli_overrides_layer) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unknown configuration field `{ignored_path}` in -c/--config override"), + )); + } + + Ok(()) +} + /// If available, apply requirements from the platform system /// `requirements.toml` location to `config_requirements_toml` by filling in /// any unset fields. @@ -627,6 +698,8 @@ struct ProjectTrustContext { project_root: AbsolutePathBuf, project_root_key: String, project_root_lookup_keys: Vec, + checkout_root: Option, + repo_root: Option, repo_root_key: Option, repo_root_lookup_keys: Option>, projects_trust: std::collections::HashMap, @@ -712,22 +785,36 @@ impl ProjectTrustContext { )), } } + + fn root_checkout_hooks_folder_for_dir(&self, dir: &AbsolutePathBuf) -> Option { + let checkout_root = self.checkout_root.as_ref()?; + let repo_root = self.repo_root.as_ref()?; + // Regular checkouts resolve both paths to the same root; linked worktrees do not. + if checkout_root == repo_root { + return None; + } + + let relative_dir = dir.as_path().strip_prefix(checkout_root.as_path()).ok()?; + Some(repo_root.join(relative_dir).join(".codex")) + } } fn project_layer_entry( dot_codex_folder: &AbsolutePathBuf, config: TomlValue, disabled_reason: Option, + hooks_config_folder_override: Option, ) -> ConfigLayerEntry { let source = ConfigLayerSource::Project { dot_codex_folder: dot_codex_folder.clone(), }; - if let Some(reason) = disabled_reason { + let entry = if let Some(reason) = disabled_reason { ConfigLayerEntry::new_disabled(source, config, reason) } else { ConfigLayerEntry::new(source, config) - } + }; + entry.with_hooks_config_folder_override(hooks_config_folder_override) } fn sanitize_project_config(config: &mut TomlValue) -> Vec { @@ -786,6 +873,7 @@ async fn project_trust_context( .first() .cloned() .unwrap_or_else(|| project_trust_key(project_root.as_path())); + let checkout_root = find_git_checkout_root(fs, cwd).await; let repo_root = resolve_root_git_project_for_trust(fs, cwd).await; let repo_root_lookup_keys = repo_root .as_ref() @@ -803,6 +891,8 @@ async fn project_trust_context( project_root, project_root_key, project_root_lookup_keys, + checkout_root, + repo_root, repo_root_key, repo_root_lookup_keys, projects_trust, @@ -944,6 +1034,24 @@ async fn find_project_root( Ok(cwd.clone()) } +async fn find_git_checkout_root( + fs: &dyn ExecutorFileSystem, + cwd: &AbsolutePathBuf, +) -> Option { + let base = match fs.get_metadata(cwd, /*sandbox*/ None).await { + Ok(metadata) if metadata.is_directory => cwd.clone(), + _ => cwd.parent()?, + }; + + for dir in base.ancestors() { + let dot_git = dir.join(".git"); + if fs.get_metadata(&dot_git, /*sandbox*/ None).await.is_ok() { + return Some(dir); + } + } + None +} + struct LoadedProjectLayers { layers: Vec, startup_warnings: Vec, @@ -961,6 +1069,7 @@ async fn load_project_layers( project_root: &AbsolutePathBuf, trust_context: &ProjectTrustContext, codex_home: &Path, + strict_config: bool, ) -> io::Result { let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?; let codex_home_normalized = @@ -995,6 +1104,7 @@ async fn load_project_layers( let decision = trust_context.decision_for_dir(&dir); let disabled_reason = trust_context.disabled_reason_for_decision(&decision); + let hooks_config_folder_override = trust_context.root_checkout_hooks_folder_for_dir(&dir); let dot_codex_normalized = normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf()); if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized { @@ -1019,21 +1129,42 @@ async fn load_project_layers( &dot_codex_abs, TomlValue::Table(toml::map::Map::new()), disabled_reason.clone(), + hooks_config_folder_override.clone(), )); continue; } }; let mut config = config; + if disabled_reason.is_none() && strict_config { + validate_config_toml_strictly( + config_file.as_path(), + &contents, + &config, + dot_codex_abs.as_path(), + )?; + } let ignored_project_config_keys = sanitize_project_config(&mut config); let config = resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; + let config = merge_root_checkout_project_hooks( + fs, + config, + hooks_config_folder_override.as_ref(), + decision.is_trusted(), + ) + .await?; if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() { startup_warnings.push(project_ignored_config_keys_warning( &dot_codex_abs, &ignored_project_config_keys, )); } - let entry = project_layer_entry(&dot_codex_abs, config, disabled_reason.clone()); + let entry = project_layer_entry( + &dot_codex_abs, + config, + disabled_reason.clone(), + hooks_config_folder_override.clone(), + ); layers.push(entry); } Err(err) => { @@ -1041,10 +1172,18 @@ async fn load_project_layers( // If there is no config.toml file, record an empty entry // for this project layer, as this may still have subfolders // that are significant in the overall ConfigLayerStack. + let config = merge_root_checkout_project_hooks( + fs, + TomlValue::Table(toml::map::Map::new()), + hooks_config_folder_override.as_ref(), + decision.is_trusted(), + ) + .await?; layers.push(project_layer_entry( &dot_codex_abs, - TomlValue::Table(toml::map::Map::new()), + config, disabled_reason, + hooks_config_folder_override, )); } else { let config_file_display = config_file.as_path().display(); @@ -1062,6 +1201,64 @@ async fn load_project_layers( startup_warnings, }) } + +/// For linked worktrees, preserve ordinary worktree-local project config while +/// replacing only hook declarations with the matching root-checkout layer. +async fn merge_root_checkout_project_hooks( + fs: &dyn ExecutorFileSystem, + mut config: TomlValue, + hooks_config_folder_override: Option<&AbsolutePathBuf>, + is_trusted: bool, +) -> io::Result { + let Some(hooks_config_folder) = hooks_config_folder_override else { + return Ok(config); + }; + let hooks_config_file = hooks_config_folder.join(CONFIG_TOML_FILE); + let root_config = match fs + .read_file_text(&hooks_config_file, /*sandbox*/ None) + .await + { + Ok(contents) => { + let parsed: TomlValue = match toml::from_str(&contents) { + Ok(parsed) => parsed, + Err(err) => { + if is_trusted { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Error parsing project hooks config file {}: {err}", + hooks_config_file.as_path().display() + ), + )); + } + TomlValue::Table(toml::map::Map::new()) + } + }; + resolve_relative_paths_in_config_toml(parsed, hooks_config_folder.as_path())? + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + TomlValue::Table(toml::map::Map::new()) + } + Err(err) => { + return Err(io::Error::new( + err.kind(), + format!( + "Failed to read project hooks config file {}: {err}", + hooks_config_file.as_path().display() + ), + )); + } + }; + + let Some(config_table) = config.as_table_mut() else { + return Ok(config); + }; + config_table.remove("hooks"); + if let Some(hooks) = root_config.get("hooks") { + config_table.insert("hooks".to_string(), hooks.clone()); + } + Ok(config) +} /// The legacy mechanism for specifying admin-enforced configuration is to read /// from a file like `/etc/codex/managed_config.toml` that has the same /// structure as `config.toml` where fields like `approval_policy` can specify diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index 420eb46147..11d3a6a403 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -57,6 +57,7 @@ pub struct ConfigProfile { pub include_apply_patch_tool: Option, pub include_permissions_instructions: Option, pub include_apps_instructions: Option, + pub include_collaboration_mode_instructions: Option, pub include_environment_context: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_freeform_apply_patch: Option, diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index c409b404d0..015cd5d239 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -14,6 +14,22 @@ use std::collections::HashMap; use std::path::PathBuf; use toml::Value as TomlValue; +/// User-facing config loading behavior that is not part of the config document. +#[derive(Debug, Default, Clone)] +pub struct ConfigLoadOptions { + pub loader_overrides: LoaderOverrides, + pub strict_config: bool, +} + +impl From for ConfigLoadOptions { + fn from(loader_overrides: LoaderOverrides) -> Self { + Self { + loader_overrides, + strict_config: false, + } + } +} + /// LoaderOverrides overrides managed configuration inputs (primarily for tests). #[derive(Debug, Default, Clone)] pub struct LoaderOverrides { @@ -66,6 +82,7 @@ pub struct ConfigLayerEntry { pub raw_toml: Option, pub version: String, pub disabled_reason: Option, + hooks_config_folder_override: Option, } impl ConfigLayerEntry { @@ -77,6 +94,7 @@ impl ConfigLayerEntry { raw_toml: None, version, disabled_reason: None, + hooks_config_folder_override: None, } } @@ -88,6 +106,7 @@ impl ConfigLayerEntry { raw_toml: Some(raw_toml), version, disabled_reason: None, + hooks_config_folder_override: None, } } @@ -103,6 +122,7 @@ impl ConfigLayerEntry { raw_toml: None, version, disabled_reason: Some(disabled_reason.into()), + hooks_config_folder_override: None, } } @@ -114,6 +134,14 @@ impl ConfigLayerEntry { self.raw_toml.as_deref() } + pub(crate) fn with_hooks_config_folder_override( + mut self, + hooks_config_folder_override: Option, + ) -> Self { + self.hooks_config_folder_override = hooks_config_folder_override; + self + } + pub fn metadata(&self) -> ConfigLayerMetadata { ConfigLayerMetadata { name: self.name.clone(), @@ -142,6 +170,18 @@ impl ConfigLayerEntry { ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None, } } + + /// Returns the `.codex/` folder that should be used for hook declarations. + /// + /// Project layers normally use their own config folder. Linked Git worktrees + /// can instead point hook discovery at the matching folder from the root + /// checkout while the rest of the project config still comes from the + /// worktree. + pub fn hooks_config_folder(&self) -> Option { + self.hooks_config_folder_override + .clone() + .or_else(|| self.config_folder()) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/codex-rs/config/src/strict_config.rs b/codex-rs/config/src/strict_config.rs new file mode 100644 index 0000000000..fb64458e07 --- /dev/null +++ b/codex-rs/config/src/strict_config.rs @@ -0,0 +1,201 @@ +//! Strict config validation built on top of serde's ignored-field tracking. + +use crate::diagnostics::ConfigDiagnosticSource; +use crate::diagnostics::ConfigError; +use crate::diagnostics::config_error_from_toml_for_source; +use crate::diagnostics::default_range; +use crate::diagnostics::span_for_config_path; +use crate::diagnostics::span_for_toml_key_path; +use crate::diagnostics::text_range_from_span; +use codex_features::is_known_feature_key; +use serde::de::DeserializeOwned; +use std::path::Path; +use toml::Value as TomlValue; + +pub fn config_error_from_ignored_toml_fields( + path: impl AsRef, + contents: &str, +) -> Option { + let source = ConfigDiagnosticSource::Path(path.as_ref()); + match toml::from_str::(contents) { + Ok(value) => { + config_error_from_ignored_toml_value_fields_for_source::(source, contents, value) + } + Err(err) => Some(config_error_from_toml_for_source(source, contents, err)), + } +} + +pub(crate) fn config_error_from_ignored_toml_value_fields( + path: impl AsRef, + contents: &str, + value: TomlValue, +) -> Option { + config_error_from_ignored_toml_value_fields_for_source::( + ConfigDiagnosticSource::Path(path.as_ref()), + contents, + value, + ) +} + +#[cfg(any(target_os = "macos", test))] +pub(crate) fn config_error_from_ignored_toml_value_fields_for_source_name( + source_name: &str, + contents: &str, + value: TomlValue, +) -> Option { + config_error_from_ignored_toml_value_fields_for_source::( + ConfigDiagnosticSource::DisplayName(source_name), + contents, + value, + ) +} + +fn config_error_from_ignored_toml_value_fields_for_source( + source: ConfigDiagnosticSource<'_>, + contents: &str, + value: TomlValue, +) -> Option { + let unknown_feature_paths = unknown_feature_toml_value_path(&value); + let mut ignored_paths = Vec::new(); + let mut ignored_callback = |ignored_path: serde_ignored::Path<'_>| { + let path_segments = ignored_path_segments(&ignored_path); + if !path_segments.is_empty() { + ignored_paths.push(path_segments); + } + }; + let deserializer = serde_ignored::Deserializer::new(value, &mut ignored_callback); + let result: Result = serde_path_to_error::deserialize(deserializer); + + match result { + Ok(_) => unknown_field_error_from_paths(source, contents, ignored_paths) + .or_else(|| unknown_field_error_from_paths(source, contents, unknown_feature_paths)), + Err(err) => { + let path_hint = err.path().clone(); + let toml_err = err.into_inner(); + let range = span_for_config_path(contents, &path_hint) + .or_else(|| toml_err.span()) + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + Some(ConfigError::new( + source.to_path_buf(), + range, + toml_err.message(), + )) + } + } +} + +pub(crate) fn ignored_toml_value_field(value: TomlValue) -> Option { + let mut ignored_paths = Vec::new(); + let result: Result = serde_ignored::deserialize(value, |ignored_path| { + let path_segments = ignored_path_segments(&ignored_path); + if !path_segments.is_empty() { + ignored_paths.push(path_segments); + } + }); + if result.is_err() { + return None; + } + + ignored_paths + .into_iter() + .next() + .map(|path_segments| path_segments.join(".")) +} + +pub(crate) fn unknown_feature_toml_value_field(value: &TomlValue) -> Option { + unknown_feature_toml_value_path(value) + .into_iter() + .next() + .map(|path_segments| path_segments.join(".")) +} + +fn unknown_field_error_from_paths( + source: ConfigDiagnosticSource<'_>, + contents: &str, + ignored_paths: Vec>, +) -> Option { + let path_segments = ignored_paths.into_iter().next()?; + let ignored_path = path_segments.join("."); + let range = span_for_toml_key_path(contents, &path_segments) + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + Some(ConfigError::new( + source.to_path_buf(), + range, + format!("unknown configuration field `{ignored_path}`"), + )) +} + +fn unknown_feature_toml_value_path(value: &TomlValue) -> Vec> { + let Some(root) = value.as_table() else { + return Vec::new(); + }; + + let mut paths = Vec::new(); + push_unknown_feature_paths(&mut paths, &["features"], root.get("features")); + + if let Some(profiles) = root.get("profiles").and_then(TomlValue::as_table) { + for (profile_name, profile) in profiles { + let prefix = ["profiles", profile_name.as_str(), "features"]; + let features = profile + .as_table() + .and_then(|profile| profile.get("features")); + push_unknown_feature_paths(&mut paths, &prefix, features); + } + } + + paths +} + +fn push_unknown_feature_paths( + paths: &mut Vec>, + prefix: &[&str], + features: Option<&TomlValue>, +) { + let Some(features) = features.and_then(TomlValue::as_table) else { + return; + }; + + for feature_key in features + .keys() + .map(String::as_str) + .filter(|key| !is_known_feature_key(key)) + { + let mut path = prefix + .iter() + .map(|segment| (*segment).to_string()) + .collect::>(); + path.push(feature_key.to_string()); + paths.push(path); + } +} + +fn ignored_path_segments(path: &serde_ignored::Path<'_>) -> Vec { + let mut segments = Vec::new(); + push_ignored_path_segments(path, &mut segments); + segments +} + +fn push_ignored_path_segments(path: &serde_ignored::Path<'_>, segments: &mut Vec) { + match path { + serde_ignored::Path::Root => {} + serde_ignored::Path::Seq { parent, index } => { + push_ignored_path_segments(parent, segments); + segments.push(index.to_string()); + } + serde_ignored::Path::Map { parent, key } => { + push_ignored_path_segments(parent, segments); + segments.push(key.clone()); + } + serde_ignored::Path::Some { parent } + | serde_ignored::Path::NewtypeStruct { parent } + | serde_ignored::Path::NewtypeVariant { parent } => { + push_ignored_path_segments(parent, segments); + } + } +} + +#[cfg(test)] +#[path = "strict_config_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/strict_config_tests.rs b/codex-rs/config/src/strict_config_tests.rs new file mode 100644 index 0000000000..4621b6b2e5 --- /dev/null +++ b/codex-rs/config/src/strict_config_tests.rs @@ -0,0 +1,112 @@ +use super::*; +use crate::config_toml::ConfigToml; +use crate::diagnostics::TextPosition; +use crate::diagnostics::TextRange; +use pretty_assertions::assert_eq; +use std::path::PathBuf; + +#[test] +fn ignored_toml_field_errors_accept_non_file_source_names() { + let source_name = "com.openai.codex:config_toml_base64"; + let contents = r#" +model = "gpt-5" +unknown_key = true"#; + + let value = toml::from_str::(contents).expect("valid TOML"); + let error = config_error_from_ignored_toml_value_fields_for_source_name::( + source_name, + contents, + value, + ) + .expect("unknown field error"); + + assert_eq!( + error, + ConfigError::new( + PathBuf::from(source_name), + TextRange { + start: TextPosition { line: 3, column: 1 }, + end: TextPosition { + line: 3, + column: 11, + }, + }, + "unknown configuration field `unknown_key`", + ) + ); +} + +#[test] +fn type_errors_take_precedence_over_ignored_fields() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +model_context_window = "wide" +unknown_key = true"#; + + let error = + config_error_from_ignored_toml_fields::(path, contents).expect("type error"); + + assert_eq!( + error, + ConfigError::new( + path.to_path_buf(), + TextRange { + start: TextPosition { + line: 2, + column: 24, + }, + end: TextPosition { + line: 2, + column: 29, + }, + }, + "invalid type: string \"wide\", expected i64", + ) + ); +} + +#[test] +fn strict_config_rejects_unknown_feature_key() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +[features] +foo = true"#; + + let error = config_error_from_ignored_toml_fields::(path, contents) + .expect("unknown feature error"); + + assert_eq!( + error, + ConfigError::new( + path.to_path_buf(), + TextRange { + start: TextPosition { line: 3, column: 1 }, + end: TextPosition { line: 3, column: 3 }, + }, + "unknown configuration field `features.foo`", + ) + ); +} + +#[test] +fn strict_config_rejects_unknown_profile_feature_key() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +[profiles.work.features] +foo = true"#; + + let error = config_error_from_ignored_toml_fields::(path, contents) + .expect("unknown feature error"); + + assert_eq!( + error, + ConfigError::new( + path.to_path_buf(), + TextRange { + start: TextPosition { line: 3, column: 1 }, + end: TextPosition { line: 3, column: 3 }, + }, + "unknown configuration field `profiles.work.features.foo`", + ) + ); +} diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 90d82da933..5b1e7d4cc8 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -306,6 +306,18 @@ pub struct TuiListKeymap { pub move_up: Option, /// Move list selection down. pub move_down: Option, + /// Move horizontally left in list pickers that support horizontal actions. + pub move_left: Option, + /// Move horizontally right in list pickers that support horizontal actions. + pub move_right: Option, + /// Move list selection up by one page. + pub page_up: Option, + /// Move list selection down by one page. + pub page_down: Option, + /// Jump to the first list item. + pub jump_top: Option, + /// Jump to the last list item. + pub jump_bottom: Option, /// Accept current selection. pub accept: Option, /// Cancel and close selection view. diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 5d252c2b44..f19429c04f 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -669,12 +669,9 @@ pub struct Tui { /// Controls whether the TUI uses the terminal's alternate screen buffer. /// - /// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere. - /// - `always`: Always use alternate screen (original behavior). + /// - `auto` (default): Use alternate screen. + /// - `always`: Always use alternate screen. /// - `never`: Never use alternate screen (inline mode only, preserves scrollback). - /// - /// Using alternate screen provides a cleaner fullscreen experience but prevents - /// scrollback in terminal multiplexers like Zellij that follow the xterm spec. #[serde(default)] pub alternate_screen: AltScreenMode, @@ -896,8 +893,7 @@ impl From for codex_app_server_protocol::SandboxSettings } } -/// Policy for building the `env` when spawning a process via either the -/// `shell` or `local_shell` tool. +/// Policy for building the `env` when spawning a process via shell-like tools. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ShellEnvironmentPolicyToml { diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 42f5ac73db..2df35b401f 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -222,6 +222,7 @@ pub struct PluginReadOutcome { pub struct PluginDetail { pub id: String, pub name: String, + pub local_version: Option, pub description: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, @@ -260,6 +261,7 @@ pub struct ConfiguredMarketplace { pub struct ConfiguredMarketplacePlugin { pub id: String, pub name: String, + pub local_version: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, @@ -1199,6 +1201,7 @@ impl PluginsManager { let installed = installed_plugins.contains(&plugin_key); let enabled = enabled_plugins.contains(&plugin_key); let mut interface = plugin.interface; + let mut local_version = plugin.local_version; if installed && matches!(&plugin.source, MarketplacePluginSource::Git { .. }) && let Ok(plugin_id) = @@ -1206,6 +1209,7 @@ impl PluginsManager { && let Some(plugin_root) = self.store.active_plugin_root(&plugin_id) && let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) { + local_version = manifest.version.clone(); let marketplace_category = interface .as_ref() .and_then(|interface| interface.category.clone()); @@ -1223,6 +1227,7 @@ impl PluginsManager { installed, enabled, name: plugin.name, + local_version, source: plugin.source, policy: plugin.policy, keywords: plugin.keywords, @@ -1273,6 +1278,10 @@ impl PluginsManager { ConfiguredMarketplacePlugin { id: plugin_key.clone(), name: plugin.plugin_id.plugin_name, + local_version: plugin + .manifest + .as_ref() + .and_then(|manifest| manifest.version.clone()), source: plugin.source, policy: plugin.policy, interface: plugin.interface, @@ -1319,6 +1328,7 @@ impl PluginsManager { return Ok(PluginDetail { id: plugin_key, name: plugin.name, + local_version: None, description: Some(description), source: plugin.source, policy: plugin.policy, @@ -1411,6 +1421,7 @@ impl PluginsManager { Ok(PluginDetail { id: plugin.id, name: plugin.name, + local_version: manifest.version.clone(), description, source: plugin.source, policy: plugin.policy, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 2e6854a2fb..82c7978e77 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -1545,6 +1545,7 @@ enabled = false ConfiguredMarketplacePlugin { id: "enabled-plugin@debug".to_string(), name: "enabled-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin")) .unwrap(), @@ -1562,6 +1563,7 @@ enabled = false ConfiguredMarketplacePlugin { id: "disabled-plugin@debug".to_string(), name: "disabled-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),) .unwrap(), @@ -1682,6 +1684,7 @@ plugins = true vec![ConfiguredMarketplacePlugin { id: "default-plugin@debug".to_string(), name: "default-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/default-plugin")).unwrap(), }, @@ -2113,6 +2116,7 @@ enabled = true vec![ConfiguredMarketplacePlugin { id: "toolkit@debug".to_string(), name: "toolkit".to_string(), + local_version: None, source: MarketplacePluginSource::Git { url: missing_remote_repo_url, path: Some("plugins/toolkit".to_string()), @@ -2229,6 +2233,7 @@ plugins = true plugins: vec![ConfiguredMarketplacePlugin { id: "linear@openai-curated".to_string(), name: "linear".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(), }, @@ -2523,6 +2528,7 @@ enabled = false vec![ConfiguredMarketplacePlugin { id: "dup-plugin@debug".to_string(), name: "dup-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(), }, @@ -2553,6 +2559,7 @@ enabled = false vec![ConfiguredMarketplacePlugin { id: "b-only-plugin@debug".to_string(), name: "b-only-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(), }, @@ -2637,6 +2644,7 @@ enabled = true plugins: vec![ConfiguredMarketplacePlugin { id: "sample-plugin@debug".to_string(), name: "sample-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(), }, diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index f66b5d1b22..d85cf2c16d 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -7,7 +7,6 @@ use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; -use dirs::home_dir; use serde::Deserialize; use serde_json::Value as JsonValue; use std::fs; @@ -59,6 +58,7 @@ pub struct MarketplaceInterface { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplacePlugin { pub name: String, + pub local_version: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, @@ -224,6 +224,16 @@ pub fn list_marketplaces( list_marketplaces_with_home(additional_roots, home_dir().as_deref()) } +pub(crate) fn home_dir() -> Option { + ["HOME", "USERPROFILE"] + .into_iter() + .filter_map(std::env::var_os) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .find(|path| path.is_absolute()) + .or_else(dirs::home_dir) +} + pub fn validate_marketplace_root(root: &Path) -> Result { let Some(path) = find_marketplace_manifest_path(root) else { return Err(MarketplaceError::InvalidMarketplaceFile { @@ -289,15 +299,22 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result return Err(err), }; + let local_version = plugin + .manifest + .as_ref() + .and_then(|manifest| manifest.version.clone()); + let keywords = plugin + .manifest + .map(|manifest| manifest.keywords) + .unwrap_or_default(); + plugins.push(MarketplacePlugin { name: plugin.plugin_id.plugin_name, + local_version, source: plugin.source, policy: plugin.policy, interface: plugin.interface, - keywords: plugin - .manifest - .map(|manifest| manifest.keywords) - .unwrap_or_default(), + keywords, }); } diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 2e93df375a..e134157177 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -388,6 +388,7 @@ fn list_marketplaces_supports_alternate_manifest_layout() { interface: None, plugins: vec![MarketplacePlugin { name: "string-source-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("plugins/string-source-plugin")) .unwrap(), @@ -453,6 +454,7 @@ fn list_marketplaces_includes_plugins_without_discoverable_manifest() { interface: None, plugins: vec![MarketplacePlugin { name: "missing-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("plugins/missing-plugin"),) .unwrap(), @@ -595,6 +597,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { plugins: vec![ MarketplacePlugin { name: "shared-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(home_root.join("home-shared")).unwrap(), }, @@ -608,6 +611,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { }, MarketplacePlugin { name: "home-only".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(home_root.join("home-only")).unwrap(), }, @@ -630,6 +634,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { plugins: vec![ MarketplacePlugin { name: "shared-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("repo-shared")).unwrap(), }, @@ -643,6 +648,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { }, MarketplacePlugin { name: "repo-only".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("repo-only")).unwrap(), }, @@ -721,6 +727,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { interface: None, plugins: vec![MarketplacePlugin { name: "local-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(), }, @@ -739,6 +746,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { interface: None, plugins: vec![MarketplacePlugin { name: "local-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(), }, @@ -813,6 +821,7 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() { interface: None, plugins: vec![MarketplacePlugin { name: "local-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(), }, @@ -976,6 +985,7 @@ fn list_marketplaces_skips_plugins_with_invalid_names_but_keeps_marketplace() { interface: None, plugins: vec![MarketplacePlugin { name: "valid-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("valid-plugin")).unwrap(), }, @@ -1093,6 +1103,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { vec![ MarketplacePlugin { name: "local-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("plugins/local-plugin")) .unwrap(), @@ -1107,6 +1118,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { }, MarketplacePlugin { name: "url-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Git { url: "https://github.com/example/plugin.git".to_string(), path: None, @@ -1123,6 +1135,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { }, MarketplacePlugin { name: "git-subdir-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Git { url: "https://github.com/owner/repo.git".to_string(), path: Some("plugins/example".to_string()), diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index bb0e13a79d..fc6fedbe23 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -39,6 +39,7 @@ pub use share::RemotePluginShareTarget; pub use share::RemotePluginShareTargetRole; pub use share::RemotePluginShareUpdateDiscoverability; pub use share::RemotePluginShareUpdateTargetsResult; +pub use share::checkout_remote_plugin_share; pub use share::delete_remote_plugin_share; pub use share::list_remote_plugin_shares; pub use share::load_plugin_share_remote_ids_by_local_path; @@ -47,10 +48,15 @@ pub use share::update_remote_plugin_share_targets; pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory"; -pub const REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "shared-with-me"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str = + "workspace-shared-with-me-private"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME: &str = + "workspace-shared-with-me-unlisted"; pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins"; pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory"; -pub const REMOTE_SHARED_WITH_ME_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME: &str = + "Shared with me (unlisted)"; const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; @@ -102,6 +108,7 @@ pub struct RemotePluginSummary { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginShareContext { pub remote_plugin_id: String, + pub remote_version: Option, pub discoverability: RemotePluginShareDiscoverability, pub share_url: Option, pub creator_account_user_id: Option, @@ -227,6 +234,9 @@ pub enum RemotePluginCatalogError { #[error("invalid plugin path `{path}`: {reason}")] InvalidPluginPath { path: PathBuf, reason: String }, + #[error("remote plugin `{remote_plugin_id}` is not available for plugin/share/checkout")] + PluginShareCheckoutNotAvailable { remote_plugin_id: String }, + #[error("failed to archive plugin at `{path}`: {source}")] Archive { path: PathBuf, @@ -285,9 +295,9 @@ impl RemotePluginScope { fn from_marketplace_name(name: &str) -> Option { match name { REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global), - REMOTE_WORKSPACE_MARKETPLACE_NAME | REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME => { - Some(Self::Workspace) - } + REMOTE_WORKSPACE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME => Some(Self::Workspace), _ => None, } } @@ -387,9 +397,11 @@ fn remote_plugin_canonical_marketplace_name( RemotePluginScope::Global => Ok(REMOTE_GLOBAL_MARKETPLACE_NAME), RemotePluginScope::Workspace => match workspace_plugin_discoverability(plugin)? { RemotePluginShareDiscoverability::Listed => Ok(REMOTE_WORKSPACE_MARKETPLACE_NAME), - RemotePluginShareDiscoverability::Unlisted - | RemotePluginShareDiscoverability::Private => { - Ok(REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME) + RemotePluginShareDiscoverability::Unlisted => { + Ok(REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME) + } + RemotePluginShareDiscoverability::Private => { + Ok(REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME) } }, } @@ -461,43 +473,81 @@ pub async fn fetch_remote_marketplaces( }; for source in sources { - let marketplace = match source { + match source { RemoteMarketplaceSource::Global => { let scope = RemotePluginScope::Global; let (directory_plugins, installed_plugins) = tokio::try_join!( fetch_directory_plugins_for_scope(config, auth, scope), fetch_installed_plugins_for_scope(config, auth, scope), )?; - build_remote_marketplace( + if let Some(marketplace) = build_remote_marketplace( scope.marketplace_name(), scope.marketplace_display_name(), directory_plugins, installed_plugins, /*include_installed_only*/ true, - )? + )? { + marketplaces.push(marketplace); + } } RemoteMarketplaceSource::WorkspaceDirectory => { let scope = RemotePluginScope::Workspace; let directory_plugins = fetch_directory_plugins_for_scope(config, auth, scope).await?; - build_remote_marketplace( + if let Some(marketplace) = build_remote_marketplace( scope.marketplace_name(), scope.marketplace_display_name(), directory_plugins, workspace_installed_plugins.clone().unwrap_or_default(), /*include_installed_only*/ false, - )? + )? { + marketplaces.push(marketplace); + } + } + RemoteMarketplaceSource::SharedWithMe => { + let private_plugins = fetch_shared_workspace_plugins(config, auth) + .await? + .into_iter() + .filter_map(|plugin| match workspace_plugin_discoverability(&plugin) { + Ok(RemotePluginShareDiscoverability::Private) => Some(Ok(plugin)), + Ok(RemotePluginShareDiscoverability::Listed) + | Ok(RemotePluginShareDiscoverability::Unlisted) => None, + Err(err) => Some(Err(err)), + }) + .collect::, _>>()?; + if let Some(marketplace) = build_remote_marketplace( + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, + private_plugins, + workspace_installed_plugins.clone().unwrap_or_default(), + /*include_installed_only*/ false, + )? { + marketplaces.push(marketplace); + } + + let unlisted_installed_plugins = workspace_installed_plugins + .clone() + .unwrap_or_default() + .into_iter() + .filter_map( + |plugin| match workspace_plugin_discoverability(&plugin.plugin) { + Ok(RemotePluginShareDiscoverability::Unlisted) => Some(Ok(plugin)), + Ok(RemotePluginShareDiscoverability::Listed) + | Ok(RemotePluginShareDiscoverability::Private) => None, + Err(err) => Some(Err(err)), + }, + ) + .collect::, _>>()?; + if let Some(marketplace) = build_remote_marketplace( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME, + Vec::new(), + unlisted_installed_plugins, + /*include_installed_only*/ true, + )? { + marketplaces.push(marketplace); + } } - RemoteMarketplaceSource::SharedWithMe => build_remote_marketplace( - REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME, - REMOTE_SHARED_WITH_ME_MARKETPLACE_DISPLAY_NAME, - fetch_shared_workspace_plugins(config, auth).await?, - workspace_installed_plugins.clone().unwrap_or_default(), - /*include_installed_only*/ false, - )?, - }; - if let Some(marketplace) = marketplace { - marketplaces.push(marketplace); } } @@ -897,6 +947,7 @@ fn remote_plugin_share_context( let discoverability = workspace_plugin_discoverability(plugin)?; Ok(Some(RemotePluginShareContext { remote_plugin_id: plugin.id.clone(), + remote_version: plugin.release.version.clone(), discoverability, share_url: plugin.share_url.clone(), creator_account_user_id: plugin.creator_account_user_id.clone(), diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 26cdce343a..c94eba428e 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -1,6 +1,7 @@ use super::REMOTE_GLOBAL_MARKETPLACE_NAME; -use super::REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME; use super::REMOTE_WORKSPACE_MARKETPLACE_NAME; +use super::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; +use super::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; use super::RemotePluginCatalogError; use super::RemotePluginScope; use super::RemotePluginServiceConfig; @@ -153,7 +154,11 @@ pub async fn sync_remote_installed_plugin_bundles_once( BTreeSet::new(), ), ( - REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), ]); @@ -298,7 +303,8 @@ fn remove_stale_remote_plugin_caches( for marketplace_name in [ REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_WORKSPACE_MARKETPLACE_NAME, - REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, ] { let marketplace_root = codex_home.join(PLUGINS_CACHE_DIR).join(marketplace_name); if !marketplace_root.exists() { @@ -457,7 +463,11 @@ mod tests { BTreeSet::new(), ), ( - REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), ]); @@ -500,12 +510,12 @@ mod tests { } #[test] - fn stale_remote_plugin_cleanup_removes_shared_with_me_cache() { + fn stale_remote_plugin_cleanup_removes_private_shared_with_me_cache() { let codex_home = tempfile::tempdir().expect("create codex home"); let cached_manifest = codex_home .path() .join(PLUGINS_CACHE_DIR) - .join(REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME) + .join(REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME) .join("private-plugin") .join("1.2.3") .join(".codex-plugin") @@ -522,7 +532,11 @@ mod tests { BTreeSet::new(), ), ( - REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), ]); @@ -531,9 +545,12 @@ mod tests { codex_home.path(), &installed_plugin_names_by_marketplace, ) - .expect("cleanup shared-with-me cache"); + .expect("cleanup private shared-with-me cache"); - assert_eq!(removed, vec!["private-plugin@shared-with-me".to_string()]); + assert_eq!( + removed, + vec!["private-plugin@workspace-shared-with-me-private".to_string()] + ); assert!(!cached_manifest.exists()); } } diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index 6afeab74b1..9b5a2572a4 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -16,10 +16,13 @@ use std::io::Write; use std::path::Path; use tracing::warn; +mod checkout; mod local_paths; const REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES: usize = 50 * 1024 * 1024; +pub use checkout::checkout_remote_plugin_share; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginShareSaveResult { pub remote_plugin_id: String, diff --git a/codex-rs/core-plugins/src/remote/share/checkout.rs b/codex-rs/core-plugins/src/remote/share/checkout.rs new file mode 100644 index 0000000000..9ccdbee851 --- /dev/null +++ b/codex-rs/core-plugins/src/remote/share/checkout.rs @@ -0,0 +1,467 @@ +use super::super::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; +use super::super::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; +use super::super::RemotePluginCatalogError; +use super::super::RemotePluginServiceConfig; +use super::local_paths; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_login::CodexAuth; +use codex_plugin::PluginId; +use codex_plugin::validate_plugin_segment; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde_json::Value as JsonValue; +use serde_json::json; +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::io::Write; +use std::path::Component; +use std::path::Path; + +const PERSONAL_MARKETPLACE_NAME: &str = "codex-curated"; +const PERSONAL_MARKETPLACE_DISPLAY_NAME: &str = "Personal"; +const PERSONAL_MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginShareCheckoutResult { + pub remote_plugin_id: String, + pub plugin_id: String, + pub plugin_name: String, + pub plugin_path: AbsolutePathBuf, + pub marketplace_name: String, + pub marketplace_path: AbsolutePathBuf, + pub remote_version: Option, +} + +pub async fn checkout_remote_plugin_share( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + codex_home: &Path, + remote_plugin_id: &str, +) -> Result { + let detail = super::super::fetch_remote_plugin_detail_with_download_urls( + config, + auth, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + remote_plugin_id, + ) + .await?; + let plugin_name = detail.summary.name.clone(); + let remote_version = detail.release_version.clone(); + validate_plugin_segment(&plugin_name, "plugin name").map_err(|reason| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "remote plugin `{remote_plugin_id}` returned invalid plugin name: {reason}" + )) + })?; + if !is_checkout_supported_share_marketplace(&detail.marketplace_name) + || detail.summary.share_context.is_none() + { + return Err(RemotePluginCatalogError::PluginShareCheckoutNotAvailable { + remote_plugin_id: remote_plugin_id.to_string(), + }); + } + + let home = crate::marketplace::home_dir().ok_or_else(|| { + RemotePluginCatalogError::UnexpectedResponse( + "could not determine home directory for personal plugin marketplace".to_string(), + ) + })?; + let home = AbsolutePathBuf::try_from(home).map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to resolve home directory for personal plugin marketplace: {err}" + )) + })?; + + let local_paths = load_share_local_paths_for_checkout(codex_home)?; + let (local_plugin_path, already_checked_out) = + editable_plugin_path_for_checkout(&home, &plugin_name, remote_plugin_id, &local_paths)?; + + let mut created_checkout_path = false; + if !already_checked_out { + let bundle = crate::remote_bundle::validate_remote_plugin_bundle( + remote_plugin_id, + &detail.marketplace_name, + &plugin_name, + detail.release_version.as_deref(), + detail.bundle_download_url.as_deref(), + ) + .map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to prepare remote plugin bundle checkout: {err}" + )) + })?; + crate::remote_bundle::download_and_extract_remote_plugin_bundle_to_path( + bundle, + local_plugin_path.clone(), + ) + .await + .map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to check out remote plugin bundle: {err}" + )) + })?; + created_checkout_path = true; + } + + let marketplace = match update_personal_marketplace( + &home, + &plugin_name, + &local_plugin_path, + detail.summary.install_policy, + detail.summary.auth_policy, + detail + .summary + .interface + .as_ref() + .and_then(|interface| interface.category.clone()), + ) { + Ok(marketplace) => marketplace, + Err(err) => { + return Err(clean_up_created_checkout_path( + created_checkout_path, + &local_plugin_path, + err, + )); + } + }; + + if let Err(err) = local_paths::record_plugin_share_local_path( + codex_home, + remote_plugin_id, + local_plugin_path.clone(), + ) { + let err = RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to record plugin share local path mapping: {err}" + )); + return Err(clean_up_created_checkout_path( + created_checkout_path, + &local_plugin_path, + err, + )); + } + + let plugin_id = PluginId::new(plugin_name.clone(), marketplace.name.clone()) + .map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to build checked out plugin id: {err}" + )) + })? + .as_key(); + + Ok(RemotePluginShareCheckoutResult { + remote_plugin_id: remote_plugin_id.to_string(), + plugin_id, + plugin_name, + plugin_path: local_plugin_path, + marketplace_name: marketplace.name, + marketplace_path: marketplace.path, + remote_version, + }) +} + +fn is_checkout_supported_share_marketplace(marketplace_name: &str) -> bool { + matches!( + marketplace_name, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME + ) +} + +fn load_share_local_paths_for_checkout( + codex_home: &Path, +) -> Result, RemotePluginCatalogError> { + match local_paths::load_plugin_share_local_paths(codex_home) { + Ok(paths) => Ok(paths), + Err(err) if err.kind() == io::ErrorKind::InvalidData => Ok(BTreeMap::new()), + Err(err) => Err(RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to load plugin share local path mapping: {err}" + ))), + } +} + +fn editable_plugin_path_for_checkout( + home: &AbsolutePathBuf, + plugin_name: &str, + remote_plugin_id: &str, + local_paths: &BTreeMap, +) -> Result<(AbsolutePathBuf, bool), RemotePluginCatalogError> { + if let Some(existing_path) = local_paths.get(remote_plugin_id) + && existing_path.as_path().exists() + { + ensure_path_can_be_listed_in_personal_marketplace(home, existing_path)?; + return Ok((existing_path.clone(), true)); + } + + let local_plugin_path = local_paths + .get(remote_plugin_id) + .cloned() + .unwrap_or_else(|| home.join("plugins").join(plugin_name)); + ensure_path_can_be_listed_in_personal_marketplace(home, &local_plugin_path)?; + + if local_plugin_path.as_path().exists() { + return Err(RemotePluginCatalogError::InvalidPluginPath { + path: local_plugin_path.to_path_buf(), + reason: format!( + "cannot check out remote plugin `{remote_plugin_id}` because the local plugin path already exists" + ), + }); + } + + Ok((local_plugin_path, false)) +} + +fn clean_up_created_checkout_path( + created_checkout_path: bool, + local_plugin_path: &AbsolutePathBuf, + original_err: RemotePluginCatalogError, +) -> RemotePluginCatalogError { + if !created_checkout_path { + return original_err; + } + + match remove_created_checkout_path(local_plugin_path) { + Ok(()) => original_err, + Err(cleanup_err) => RemotePluginCatalogError::UnexpectedResponse(format!( + "{original_err}; additionally failed to clean up checked out plugin path `{}`: {cleanup_err}", + local_plugin_path.display() + )), + } +} + +fn remove_created_checkout_path(local_plugin_path: &AbsolutePathBuf) -> io::Result<()> { + if local_plugin_path.as_path().is_dir() { + fs::remove_dir_all(local_plugin_path.as_path()) + } else { + fs::remove_file(local_plugin_path.as_path()) + } +} + +fn ensure_path_can_be_listed_in_personal_marketplace( + home: &AbsolutePathBuf, + path: &AbsolutePathBuf, +) -> Result<(), RemotePluginCatalogError> { + personal_marketplace_relative_plugin_path(home, path).map(|_| ()) +} + +struct PersonalMarketplaceUpdate { + name: String, + path: AbsolutePathBuf, +} + +fn update_personal_marketplace( + home: &AbsolutePathBuf, + plugin_name: &str, + local_plugin_path: &AbsolutePathBuf, + install_policy: PluginInstallPolicy, + auth_policy: PluginAuthPolicy, + category: Option, +) -> Result { + let marketplace_path = home.join(PERSONAL_MARKETPLACE_RELATIVE_PATH); + let relative_plugin_path = personal_marketplace_relative_plugin_path(home, local_plugin_path)?; + let mut marketplace = read_or_create_personal_marketplace(marketplace_path.as_path())?; + let Some(marketplace_object) = marketplace.as_object_mut() else { + return Err(invalid_marketplace_file( + marketplace_path.as_path(), + "personal marketplace file must contain a JSON object", + )); + }; + let marketplace_name = marketplace_object + .entry("name") + .or_insert_with(|| json!(PERSONAL_MARKETPLACE_NAME)) + .as_str() + .ok_or_else(|| { + invalid_marketplace_file( + marketplace_path.as_path(), + "marketplace name must be a string", + ) + })? + .to_string(); + validate_plugin_segment(&marketplace_name, "marketplace name").map_err(|reason| { + invalid_marketplace_file( + marketplace_path.as_path(), + &format!("marketplace name is invalid: {reason}"), + ) + })?; + + let plugins = marketplace_object + .entry("plugins") + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or_else(|| { + invalid_marketplace_file( + marketplace_path.as_path(), + "marketplace plugins must be an array", + ) + })?; + + let new_entry = personal_marketplace_plugin_entry( + plugin_name, + &relative_plugin_path, + install_policy, + auth_policy, + category, + ); + + if let Some(existing_entry) = plugins + .iter_mut() + .find(|entry| entry.get("name").and_then(JsonValue::as_str) == Some(plugin_name)) + { + let existing_path = existing_entry + .get("source") + .and_then(|source| source.get("path")) + .and_then(JsonValue::as_str); + if existing_path != Some(relative_plugin_path.as_str()) { + return Err(invalid_marketplace_file( + marketplace_path.as_path(), + &format!( + "marketplace already contains plugin `{plugin_name}` with a different source path" + ), + )); + } + *existing_entry = new_entry; + } else { + plugins.push(new_entry); + } + + let contents = serde_json::to_string_pretty(&marketplace) + .map_err(|err| RemotePluginCatalogError::UnexpectedResponse(err.to_string()))?; + write_json_atomically(marketplace_path.as_path(), &format!("{contents}\n")).map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to update personal plugin marketplace: {err}" + )) + })?; + + Ok(PersonalMarketplaceUpdate { + name: marketplace_name, + path: marketplace_path, + }) +} + +fn read_or_create_personal_marketplace( + marketplace_path: &Path, +) -> Result { + match std::fs::read_to_string(marketplace_path) { + Ok(contents) => serde_json::from_str(&contents).map_err(|err| { + invalid_marketplace_file( + marketplace_path, + &format!("failed to parse personal marketplace file: {err}"), + ) + }), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(json!({ + "name": PERSONAL_MARKETPLACE_NAME, + "interface": { + "displayName": PERSONAL_MARKETPLACE_DISPLAY_NAME, + }, + "plugins": [], + })), + Err(err) => Err(RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to read personal plugin marketplace: {err}" + ))), + } +} + +fn personal_marketplace_plugin_entry( + plugin_name: &str, + relative_plugin_path: &str, + install_policy: PluginInstallPolicy, + auth_policy: PluginAuthPolicy, + category: Option, +) -> JsonValue { + let mut entry = json!({ + "name": plugin_name, + "source": { + "source": "local", + "path": relative_plugin_path, + }, + "policy": { + "installation": plugin_install_policy_value(install_policy), + "authentication": plugin_auth_policy_value(auth_policy), + }, + }); + if let Some(category) = category + && !category.trim().is_empty() + && let Some(object) = entry.as_object_mut() + { + object.insert("category".to_string(), json!(category)); + } + entry +} + +fn plugin_install_policy_value(policy: PluginInstallPolicy) -> &'static str { + match policy { + PluginInstallPolicy::NotAvailable => "NOT_AVAILABLE", + PluginInstallPolicy::Available => "AVAILABLE", + PluginInstallPolicy::InstalledByDefault => "INSTALLED_BY_DEFAULT", + } +} + +fn plugin_auth_policy_value(policy: PluginAuthPolicy) -> &'static str { + match policy { + PluginAuthPolicy::OnInstall => "ON_INSTALL", + PluginAuthPolicy::OnUse => "ON_USE", + } +} + +fn personal_marketplace_relative_plugin_path( + home: &AbsolutePathBuf, + local_plugin_path: &AbsolutePathBuf, +) -> Result { + let relative = local_plugin_path + .as_path() + .strip_prefix(home.as_path()) + .map_err(|_| RemotePluginCatalogError::InvalidPluginPath { + path: local_plugin_path.to_path_buf(), + reason: "local plugin path must be inside the home directory to be listed in the personal marketplace".to_string(), + })?; + let mut segments = Vec::new(); + for component in relative.components() { + match component { + Component::Normal(segment) => { + let segment = segment.to_str().ok_or_else(|| { + RemotePluginCatalogError::InvalidPluginPath { + path: local_plugin_path.to_path_buf(), + reason: "local plugin path contains non-UTF-8 segments".to_string(), + } + })?; + segments.push(segment.to_string()); + } + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(RemotePluginCatalogError::InvalidPluginPath { + path: local_plugin_path.to_path_buf(), + reason: + "local plugin path cannot be represented as a personal marketplace path" + .to_string(), + }); + } + } + } + if segments.is_empty() { + return Err(RemotePluginCatalogError::InvalidPluginPath { + path: local_plugin_path.to_path_buf(), + reason: "local plugin path must not be the home directory".to_string(), + }); + } + Ok(format!("./{}", segments.join("/"))) +} + +fn invalid_marketplace_file(path: &Path, message: &str) -> RemotePluginCatalogError { + RemotePluginCatalogError::InvalidPluginPath { + path: path.to_path_buf(), + reason: message.to_string(), + } +} + +fn write_json_atomically(write_path: &Path, contents: &str) -> io::Result<()> { + let parent = write_path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("path {} has no parent directory", write_path.display()), + ) + })?; + std::fs::create_dir_all(parent)?; + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(contents.as_bytes())?; + tmp.persist(write_path).map_err(|err| err.error)?; + Ok(()) +} diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index 8b27b28c3f..0b8f8f2fb5 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -97,6 +97,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { + "version": "0.1.0", "display_name": "Demo Plugin", "description": "Demo plugin description", "interface": { @@ -585,11 +586,12 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { vec![ RemotePluginShareSummary { summary: RemotePluginSummary { - id: "demo-plugin@shared-with-me".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), remote_plugin_id: "plugins_123".to_string(), name: "demo-plugin".to_string(), share_context: Some(RemotePluginShareContext { remote_plugin_id: "plugins_123".to_string(), + remote_version: Some("0.1.0".to_string()), discoverability: RemotePluginShareDiscoverability::Private, share_url: Some( "https://chatgpt.example/plugins/share/share-key-1".to_string(), @@ -623,11 +625,12 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { }, RemotePluginShareSummary { summary: RemotePluginSummary { - id: "demo-plugin@shared-with-me".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), remote_plugin_id: "plugins_456".to_string(), name: "demo-plugin".to_string(), share_context: Some(RemotePluginShareContext { remote_plugin_id: "plugins_456".to_string(), + remote_version: Some("0.1.0".to_string()), discoverability: RemotePluginShareDiscoverability::Private, share_url: None, creator_account_user_id: None, diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index 92d5613846..ed7cd56a86 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -239,6 +239,26 @@ pub async fn download_and_install_remote_plugin_bundle( })? } +pub(crate) async fn download_and_extract_remote_plugin_bundle_to_path( + bundle: ValidatedRemotePluginBundle, + destination: AbsolutePathBuf, +) -> Result { + let bundle_bytes = download_remote_plugin_bundle_with_limit( + &bundle.bundle_download_url, + /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES, + ) + .await?; + tokio::task::spawn_blocking(move || { + extract_remote_plugin_bundle_to_path(bundle, bundle_bytes, destination) + }) + .await + .map_err(|err| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "failed to join remote plugin bundle extraction task: {err}" + )) + })? +} + async fn download_remote_plugin_bundle_with_limit( bundle_download_url: &str, max_bytes: u64, @@ -359,6 +379,63 @@ fn install_remote_plugin_bundle( .map_err(RemotePluginBundleInstallError::from) } +fn extract_remote_plugin_bundle_to_path( + bundle: ValidatedRemotePluginBundle, + bundle_bytes: Vec, + destination: AbsolutePathBuf, +) -> Result { + if destination.as_path().exists() { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "plugin checkout destination already exists: {}", + destination.display() + ))); + } + + let parent = destination.as_path().parent().ok_or_else(|| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "plugin checkout destination has no parent: {}", + destination.display() + )) + })?; + fs::create_dir_all(parent).map_err(|source| { + RemotePluginBundleInstallError::io("failed to create plugin checkout directory", source) + })?; + + let extract_dir = tempfile::Builder::new() + .prefix("remote-plugin-checkout-") + .tempdir_in(parent) + .map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle extraction directory", + source, + ) + })?; + + extract_plugin_bundle_tar_gz(&bundle_bytes, extract_dir.path())?; + let plugin_root = find_extracted_plugin_root(extract_dir.path())?; + let manifest = crate::manifest::load_plugin_manifest(&plugin_root).ok_or_else(|| { + RemotePluginBundleInstallError::InvalidBundle( + "remote plugin bundle did not contain a valid plugin.json".to_string(), + ) + })?; + if manifest.name != bundle.plugin_id.plugin_name { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "plugin.json name `{}` does not match remote plugin name `{}`", + manifest.name, bundle.plugin_id.plugin_name + ))); + } + + let staged_path = extract_dir.keep(); + fs::rename(&staged_path, destination.as_path()).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to activate checked out plugin directory", + source, + ) + })?; + + Ok(destination) +} + fn extract_plugin_bundle_tar_gz( bytes: &[u8], destination: &Path, diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index e826027cba..361e958e86 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -1,13 +1,5 @@ load("//:defs.bzl", "codex_rust_crate") -filegroup( - name = "model_availability_nux_fixtures", - srcs = [ - "tests/cli_responses_fixture.sse", - ], - visibility = ["//visibility:public"], -) - codex_rust_crate( name = "core", crate_name = "codex_core", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 86e7c07ad9..8472918dca 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -60,7 +60,6 @@ codex-sandboxing = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } codex-thread-store = { workspace = true } -codex-tool-api = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } @@ -70,7 +69,6 @@ codex-utils-output-truncation = { workspace = true } codex-utils-path = { workspace = true } codex-utils-plugins = { workspace = true } codex-utils-pty = { workspace = true } -codex-utils-readiness = { workspace = true } codex-utils-string = { workspace = true } codex-utils-stream-parser = { workspace = true } codex-utils-template = { workspace = true } @@ -78,7 +76,6 @@ codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows- csv = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } -env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5af6b7435b..faf7ec6817 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -62,17 +62,17 @@ "type": "object" }, "AltScreenMode": { - "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n**Background:** The alternate screen buffer provides a cleaner fullscreen experience without polluting the terminal's scrollback history. However, it conflicts with terminal multiplexers like Zellij that strictly follow the xterm specification, which defines that alternate screen buffers should not have scrollback.\n\n**Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode (see https://github.com/zellij-org/zellij/pull/1032) to comply with the xterm spec. This is by design and not configurable in Zellij—there is no option to enable scrollback in alternate screen mode.\n\n**Solution:** This setting provides a pragmatic workaround: - `auto` (default): Automatically detect the terminal multiplexer. If running in Zellij, disable alternate screen to preserve scrollback. Enable it everywhere else. - `always`: Always use alternate screen mode (original behavior before this fix). - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback in all multiplexers.\n\nThe CLI flag `--no-alt-screen` can override this setting at runtime.", + "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n- `auto` (default): Use alternate screen mode. - `always`: Always use alternate screen mode. - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback.\n\nThe CLI flag `--no-alt-screen` can override this setting at runtime.", "oneOf": [ { - "description": "Auto-detect: disable alternate screen in Zellij, enable elsewhere.", + "description": "Use alternate screen mode.", "enum": [ "auto" ], "type": "string" }, { - "description": "Always use alternate screen (original behavior).", + "description": "Always use alternate screen mode.", "enum": [ "always" ], @@ -496,6 +496,9 @@ "plugin_hooks": { "type": "boolean" }, + "plugin_sharing": { + "type": "boolean" + }, "plugins": { "type": "boolean" }, @@ -622,6 +625,9 @@ "include_apps_instructions": { "type": "boolean" }, + "include_collaboration_mode_instructions": { + "type": "boolean" + }, "include_environment_context": { "type": "boolean" }, @@ -1479,6 +1485,9 @@ "minimum": 1.0, "type": "integer" }, + "non_code_mode_only": { + "type": "boolean" + }, "root_agent_usage_hint_text": { "type": "string" }, @@ -2338,7 +2347,7 @@ }, "ShellEnvironmentPolicyToml": { "additionalProperties": false, - "description": "Policy for building the `env` when spawning a process via either the `shell` or `local_shell` tool.", + "description": "Policy for building the `env` when spawning a process via shell-like tools.", "properties": { "exclude": { "description": "List of regular expressions.", @@ -2530,7 +2539,7 @@ } ], "default": "auto", - "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n- `auto` (default): Disable alternate screen in Zellij, enable elsewhere. - `always`: Always use alternate screen (original behavior). - `never`: Never use alternate screen (inline mode only, preserves scrollback).\n\nUsing alternate screen provides a cleaner fullscreen experience but prevents scrollback in terminal multiplexers like Zellij that follow the xterm spec." + "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n- `auto` (default): Use alternate screen. - `always`: Always use alternate screen. - `never`: Never use alternate screen (inline mode only, preserves scrollback)." }, "animations": { "default": true, @@ -2600,8 +2609,14 @@ "list": { "accept": null, "cancel": null, + "jump_bottom": null, + "jump_top": null, "move_down": null, - "move_up": null + "move_left": null, + "move_right": null, + "move_up": null, + "page_down": null, + "page_up": null }, "pager": { "close": null, @@ -3246,8 +3261,14 @@ "default": { "accept": null, "cancel": null, + "jump_bottom": null, + "jump_top": null, "move_down": null, - "move_up": null + "move_left": null, + "move_right": null, + "move_up": null, + "page_down": null, + "page_up": null } }, "pager": { @@ -3344,6 +3365,22 @@ ], "description": "Cancel and close selection view." }, + "jump_bottom": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Jump to the last list item." + }, + "jump_top": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Jump to the first list item." + }, "move_down": { "allOf": [ { @@ -3352,6 +3389,22 @@ ], "description": "Move list selection down." }, + "move_left": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move horizontally left in list pickers that support horizontal actions." + }, + "move_right": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move horizontally right in list pickers that support horizontal actions." + }, "move_up": { "allOf": [ { @@ -3359,6 +3412,22 @@ } ], "description": "Move list selection up." + }, + "page_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move list selection down by one page." + }, + "page_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move list selection up by one page." } }, "type": "object" @@ -4170,6 +4239,9 @@ "plugin_hooks": { "type": "boolean" }, + "plugin_sharing": { + "type": "boolean" + }, "plugins": { "type": "boolean" }, @@ -4358,6 +4430,10 @@ "description": "Whether to inject the `` developer block.", "type": "boolean" }, + "include_collaboration_mode_instructions": { + "description": "Whether to inject the `` developer block.", + "type": "boolean" + }, "include_environment_context": { "description": "Whether to inject the `` user block.", "type": "boolean" diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 079ee61f01..d973f40e45 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -233,32 +233,31 @@ impl AgentControl { // The same `AgentControl` is sent to spawn the thread. let new_thread = match (session_source, options.fork_mode.as_ref()) { (Some(session_source), Some(_)) => { - self.spawn_forked_thread( + Box::pin(self.spawn_forked_thread( &state, config, session_source, &options, inherited_shell_snapshot, inherited_exec_policy, - ) + )) .await? } (Some(session_source), None) => { - state - .spawn_new_thread_with_source( - config.clone(), - self.clone(), - session_source, - /*thread_source*/ Some(ThreadSource::Subagent), - /*persist_extended_history*/ false, - /*metrics_service_name*/ None, - inherited_shell_snapshot, - inherited_exec_policy, - options.environments.clone(), - ) - .await? + Box::pin(state.spawn_new_thread_with_source( + config.clone(), + self.clone(), + session_source, + /*thread_source*/ Some(ThreadSource::Subagent), + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + inherited_shell_snapshot, + inherited_exec_policy, + options.environments.clone(), + )) + .await? } - (None, _) => state.spawn_new_thread(config.clone(), self.clone()).await?, + (None, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, }; agent_metadata.agent_id = Some(new_thread.thread_id); reservation.commit(agent_metadata.clone()); diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 545d131c69..f604a63458 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -112,7 +112,6 @@ 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; use codex_feedback::FeedbackRequestTags; @@ -772,7 +771,6 @@ impl ModelClient { pub fn responses_websocket_enabled(&self) -> bool { if !self.state.provider.info().supports_websockets || self.state.disable_websockets.load(Ordering::Relaxed) - || (*CODEX_RS_SSE_FIXTURE).is_some() { return false; } @@ -1194,8 +1192,7 @@ impl ModelClientSession { /// Streams a turn via the OpenAI Responses API. /// - /// Handles SSE fixtures, reasoning summaries, verbosity, and the - /// `text` controls used for output schemas. + /// Handles reasoning summaries, verbosity, and the `text` controls used for output schemas. #[allow(clippy::too_many_arguments)] #[instrument( name = "model_client.stream_responses_api", @@ -1221,21 +1218,6 @@ impl ModelClientSession { turn_metadata_header: Option<&str>, inference_trace: &InferenceTraceContext, ) -> Result { - if let Some(path) = &*CODEX_RS_SSE_FIXTURE { - warn!(path, "Streaming from fixture"); - let stream = codex_api::stream_from_fixture( - path, - self.client.state.provider.info().stream_idle_timeout(), - ) - .map_err(map_api_error)?; - let (stream, _last_request_rx) = map_response_stream( - stream, - session_telemetry.clone(), - InferenceTraceAttempt::disabled(), - ); - return Ok(stream); - } - let auth_manager = self.client.state.provider.auth_manager(); let mut auth_recovery = auth_manager .as_ref() diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index efe2670652..2061d53c80 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -126,7 +126,7 @@ fn reserialize_shell_outputs(items: &mut [ResponseItem]) { } fn is_shell_tool_name(name: &str) -> bool { - matches!(name, "shell" | "container.exec") + name == "shell" } #[derive(Deserialize)] diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index c110d30f66..be5b0ccb88 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -140,6 +140,21 @@ impl CodexThread { self.codex.session_loop_termination.clone().await; } + pub(crate) fn emit_thread_resume_lifecycle(&self) { + for contributor in self + .codex + .session + .services + .extensions + .thread_lifecycle_contributors() + { + contributor.on_thread_resume(codex_extension_api::ThreadResumeInput { + session_store: &self.codex.session.services.session_extension_data, + thread_store: &self.codex.session.services.thread_extension_data, + }); + } + } + pub async fn apply_goal_resume_runtime_effects(&self) -> anyhow::Result<()> { self.codex .session diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 7f6ea61a5b..4dafb95f8e 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -401,7 +401,7 @@ mod tests { message("assistant", "final", Some(MessagePhase::FinalAnswer)), ResponseItem::FunctionCall { id: None, - name: "shell".to_string(), + name: "shell_command".to_string(), namespace: None, arguments: "{}".to_string(), call_id: "call_1".to_string(), diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 6296d13886..136a422849 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -18,12 +18,12 @@ use codex_config::RequirementSource; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; use codex_config::ThreadConfigSource; +use codex_config::config_error_from_ignored_toml_fields; use codex_config::config_error_from_toml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_config::loader::load_config_layers_state; use codex_config::loader::load_requirements_toml; -use codex_config::version_for_toml; use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; @@ -68,6 +68,45 @@ async fn make_config_for_test( .await } +async fn write_linked_worktree_pointer( + repo_root: &Path, + worktree_root: &Path, +) -> std::io::Result<()> { + let worktree_git_dir = repo_root.join(".git/worktrees/feature-x"); + tokio::fs::create_dir_all(&worktree_git_dir).await?; + tokio::fs::write( + worktree_root.join(".git"), + format!("gitdir: {}\n", worktree_git_dir.display()), + ) + .await +} + +async fn write_project_hook_config( + dot_codex_folder: &Path, + foo: Option<&str>, + command: &str, +) -> std::io::Result<()> { + tokio::fs::create_dir_all(dot_codex_folder).await?; + let foo = foo + .map(|value| format!("foo = \"{value}\"\n\n")) + .unwrap_or_default(); + tokio::fs::write( + dot_codex_folder.join(CONFIG_TOML_FILE), + format!( + r#"{foo}[hooks] + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "{command}" +"# + ), + ) + .await +} + #[tokio::test] async fn cli_overrides_resolve_relative_paths_against_cwd() -> std::io::Result<()> { let codex_home = tempdir().expect("tempdir"); @@ -95,7 +134,8 @@ async fn cli_overrides_resolve_relative_paths_against_cwd() -> std::io::Result<( #[tokio::test] async fn returns_config_error_for_invalid_user_config_toml() { let tmp = tempdir().expect("tempdir"); - let contents = "model = \"gpt-4\"\ninvalid = ["; + let contents = r#"model = "gpt-4" +invalid = ["#; let config_path = tmp.path().join(CONFIG_TOML_FILE); std::fs::write(&config_path, contents).expect("write config"); @@ -123,7 +163,8 @@ async fn ignore_user_config_keeps_empty_user_layer() -> std::io::Result<()> { let tmp = tempdir().expect("tempdir"); std::fs::write( tmp.path().join(CONFIG_TOML_FILE), - "model = \"from-user-config\"\ninvalid = [", + r#"model = "from-user-config" +invalid = ["#, ) .expect("write config"); @@ -181,7 +222,8 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io: async fn returns_config_error_for_invalid_managed_config_toml() { let tmp = tempdir().expect("tempdir"); let managed_path = tmp.path().join("managed_config.toml"); - let contents = "model = \"gpt-4\"\ninvalid = ["; + let contents = r#"model = "gpt-4" +invalid = ["#; std::fs::write(&managed_path, contents).expect("write managed config"); let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()); @@ -228,10 +270,221 @@ async fn returns_config_error_for_schema_error_in_user_config() { assert_eq!(config_error, &expected_config_error); } +#[tokio::test] +async fn top_level_allow_managed_hooks_only_in_user_config_does_not_enable_requirements_policy() +-> std::io::Result<()> { + let tmp = tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + "allow_managed_hooks_only = true", + ) + .expect("write config"); + + let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + tmp.path(), + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + assert_eq!(layers.requirements_toml().allow_managed_hooks_only, None); + assert!(layers.requirements().allow_managed_hooks_only.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn hooks_allow_managed_hooks_only_in_user_config_does_not_enable_requirements_policy() +-> std::io::Result<()> { + let tmp = tempdir().expect("tempdir"); + let contents = r#" +[hooks] +allow_managed_hooks_only = true + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /tmp/user-hook.py" +"#; + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), contents).expect("write config"); + + let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + tmp.path(), + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + assert!( + layers + .get_user_layer() + .and_then(|layer| layer.config.get("hooks")) + .is_some(), + "hooks should still deserialize from config.toml" + ); + assert_eq!(layers.requirements_toml().allow_managed_hooks_only, None); + assert!(layers.requirements().allow_managed_hooks_only.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn strict_config_rejects_unknown_user_config_key() { + let tmp = tempdir().expect("tempdir"); + let contents = r#"model = "gpt-5" +unknown_key = true"#; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + let config_error = config_error_from_io(&err); + let expected_config_error = + config_error_from_ignored_toml_fields::(&config_path, contents) + .expect("unknown field error"); + assert_eq!(config_error, &expected_config_error); +} + +#[tokio::test] +async fn strict_config_rejects_unknown_cli_override_key() { + let tmp = tempdir().expect("tempdir"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .cli_overrides(vec![( + "foo".to_string(), + TomlValue::String("bar".to_string()), + )]) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + assert_eq!( + err.to_string(), + "unknown configuration field `foo` in -c/--config override" + ); +} + +#[tokio::test] +async fn strict_config_rejects_unknown_cli_override_key_with_relative_path_override() { + let tmp = tempdir().expect("tempdir"); + let instructions_path = tmp.path().join("instructions.md"); + std::fs::write(&instructions_path, "instructions").expect("write instructions"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .cli_overrides(vec![ + ( + "model_instructions_file".to_string(), + TomlValue::String("instructions.md".to_string()), + ), + ("foo".to_string(), TomlValue::String("bar".to_string())), + ]) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + assert_eq!( + err.to_string(), + "unknown configuration field `foo` in -c/--config override" + ); +} + +#[tokio::test] +async fn strict_config_rejects_unknown_feature_cli_override_key() { + let tmp = tempdir().expect("tempdir"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .cli_overrides(vec![("features.foo".to_string(), TomlValue::Boolean(true))]) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + assert_eq!( + err.to_string(), + "unknown configuration field `features.foo` in -c/--config override" + ); +} + +#[tokio::test] +async fn strict_config_rejects_unknown_feature_user_config_key() { + let tmp = tempdir().expect("tempdir"); + let contents = r#"[features] +foo = true"#; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + let config_error = config_error_from_io(&err); + assert_eq!( + config_error.message, + "unknown configuration field `features.foo`" + ); + assert_eq!(config_error.range.start.line, 2); + assert_eq!(config_error.range.start.column, 1); +} + +#[test] +fn strict_config_points_to_unknown_nested_key() { + let tmp = tempdir().expect("tempdir"); + let contents = r#"[mcp_servers.local] +command = "echo" +unknown_key = true"#; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let error = config_error_from_ignored_toml_fields::(&config_path, contents) + .expect("unknown field error"); + + assert_eq!( + error.message, + "unknown configuration field `mcp_servers.local.unknown_key`" + ); + assert_eq!(error.range.start.line, 3); + assert_eq!(error.range.start.column, 1); +} #[test] fn schema_error_points_to_feature_value() { let tmp = tempdir().expect("tempdir"); - let contents = "[features]\ncollaboration_modes = \"true\""; + let contents = r#"[features] +collaboration_modes = "true""#; let config_path = tmp.path().join(CONFIG_TOML_FILE); std::fs::write(&config_path, contents).expect("write config"); @@ -321,18 +574,13 @@ async fn returns_empty_when_all_layers_missing() { let user_layer = layers .get_user_layer() .expect("expected a user layer even when CODEX_HOME/config.toml does not exist"); - assert_eq!( - &ConfigLayerEntry { - name: ConfigLayerSource::User { - file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()) - }, - config: TomlValue::Table(toml::map::Map::new()), - raw_toml: None, - version: version_for_toml(&TomlValue::Table(toml::map::Map::new())), - disabled_reason: None, + let expected_user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()), }, - user_layer, + TomlValue::Table(toml::map::Map::new()), ); + assert_eq!(&expected_user_layer, user_layer); assert_eq!( user_layer.config, TomlValue::Table(toml::map::Map::new()), @@ -613,7 +861,12 @@ async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<() let tmp = tempdir()?; let managed_path = tmp.path().join("managed_config.toml"); - tokio::fs::write(&managed_path, "approval_policy = \"on-request\"\n").await?; + tokio::fs::write( + &managed_path, + r#"approval_policy = "on-request" +"#, + ) + .await?; let mut loader_overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path); loader_overrides.macos_managed_config_requirements_base64 = Some( @@ -777,6 +1030,7 @@ allowed_approval_policies = ["on-request"] allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -834,6 +1088,7 @@ allowed_approval_policies = ["on-request"] allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1042,6 +1297,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1095,11 +1351,17 @@ async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result< let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; let managed_config_path = tmp.path().join("managed_config.toml"); - tokio::fs::write(&managed_config_path, "approval_policy = \"never\"\n").await?; + tokio::fs::write( + &managed_config_path, + r#"approval_policy = "never" +"#, + ) + .await?; let system_requirements_path = tmp.path().join("requirements.toml"); tokio::fs::write( &system_requirements_path, - "allowed_sandbox_modes = [\"read-only\"]\n", + r#"allowed_sandbox_modes = ["read-only"] +"#, ) .await?; @@ -1285,12 +1547,14 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { tokio::fs::write( project_root.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"root\"\n", + r#"foo = "root" +"#, ) .await?; tokio::fs::write( nested.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"child\"\n", + r#"foo = "child" +"#, ) .await?; @@ -1339,6 +1603,260 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { Ok(()) } +#[tokio::test] +async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_hooks() +-> std::io::Result<()> { + let tmp = tempdir()?; + let repo_root = tmp.path().join("repo"); + let repo_child = repo_root.join("child"); + let worktree_root = tmp.path().join("worktree"); + let worktree_child = worktree_root.join("child"); + + tokio::fs::create_dir_all(worktree_root.join(".codex")).await?; + tokio::fs::create_dir_all(worktree_child.join(".codex")).await?; + write_linked_worktree_pointer(&repo_root, &worktree_root).await?; + write_project_hook_config( + &repo_root.join(".codex"), + Some("repo-root"), + "echo repo root hook", + ) + .await?; + write_project_hook_config( + &repo_child.join(".codex"), + Some("repo-child"), + "echo repo child hook", + ) + .await?; + write_project_hook_config( + &worktree_root.join(".codex"), + Some("worktree-root"), + "echo worktree root hook", + ) + .await?; + write_project_hook_config( + &worktree_child.join(".codex"), + Some("worktree-child"), + "echo worktree child hook", + ) + .await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test( + &codex_home, + &repo_root, + TrustLevel::Trusted, + /*project_root_markers*/ None, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&worktree_child)?; + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let project_layers: Vec<_> = layers + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) + .collect(); + assert_eq!(project_layers.len(), 2); + assert_eq!( + project_layers[0].hooks_config_folder(), + Some(AbsolutePathBuf::from_absolute_path( + repo_child.join(".codex") + )?) + ); + assert_eq!( + project_layers[1].hooks_config_folder(), + Some(AbsolutePathBuf::from_absolute_path( + repo_root.join(".codex") + )?) + ); + assert_eq!( + project_layers[0] + .config + .get("foo") + .and_then(TomlValue::as_str), + Some("worktree-child") + ); + assert_eq!( + project_hook_command(project_layers[0]), + Some("echo repo child hook") + ); + assert_eq!( + project_layers[1] + .config + .get("foo") + .and_then(TomlValue::as_str), + Some("worktree-root") + ); + assert_eq!( + project_hook_command(project_layers[1]), + Some("echo repo root hook") + ); + + Ok(()) +} + +#[tokio::test] +async fn linked_worktree_project_layers_use_root_repo_hooks_without_worktree_config_toml() +-> std::io::Result<()> { + let tmp = tempdir()?; + let repo_root = tmp.path().join("repo"); + let worktree_root = tmp.path().join("worktree"); + + tokio::fs::create_dir_all(worktree_root.join(".codex")).await?; + write_linked_worktree_pointer(&repo_root, &worktree_root).await?; + write_project_hook_config( + &repo_root.join(".codex"), + /*foo*/ None, + "echo repo root hook", + ) + .await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test( + &codex_home, + &repo_root, + TrustLevel::Trusted, + /*project_root_markers*/ None, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&worktree_root)?; + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let project_layers: Vec<_> = layers + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) + .collect(); + assert_eq!(project_layers.len(), 1); + assert_eq!( + project_layers[0].hooks_config_folder(), + Some(AbsolutePathBuf::from_absolute_path( + repo_root.join(".codex") + )?) + ); + assert_eq!( + project_hook_command(project_layers[0]), + Some("echo repo root hook") + ); + + Ok(()) +} + +#[tokio::test] +async fn nested_project_root_markers_do_not_redirect_regular_repo_hooks() -> std::io::Result<()> { + let tmp = tempdir()?; + let repo_root = tmp.path().join("repo"); + let project_root = repo_root.join("project"); + let nested = project_root.join("child"); + + tokio::fs::create_dir_all(repo_root.join(".git")).await?; + tokio::fs::create_dir_all(&project_root).await?; + tokio::fs::write(project_root.join(".hg"), "hg").await?; + write_project_hook_config( + &repo_root.join(".codex"), + /*foo*/ None, + "echo repo root hook", + ) + .await?; + write_project_hook_config( + &project_root.join(".codex"), + /*foo*/ None, + "echo project root hook", + ) + .await?; + write_project_hook_config( + &nested.join(".codex"), + /*foo*/ None, + "echo nested hook", + ) + .await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test( + &codex_home, + &project_root, + TrustLevel::Trusted, + Some(vec![".hg".to_string()]), + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let project_layers: Vec<_> = layers + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) + .collect(); + assert_eq!(project_layers.len(), 2); + assert_eq!( + project_layers[0].hooks_config_folder(), + Some(AbsolutePathBuf::from_absolute_path(nested.join(".codex"))?) + ); + assert_eq!( + project_layers[1].hooks_config_folder(), + Some(AbsolutePathBuf::from_absolute_path( + project_root.join(".codex") + )?) + ); + assert_eq!( + project_hook_command(project_layers[0]), + Some("echo nested hook") + ); + assert_eq!( + project_hook_command(project_layers[1]), + Some("echo project root hook") + ); + + Ok(()) +} + +fn project_hook_command(layer: &ConfigLayerEntry) -> Option<&str> { + layer + .config + .get("hooks")? + .get("PreToolUse")? + .as_array()? + .first()? + .get("hooks")? + .as_array()? + .first()? + .get("command")? + .as_str() +} + #[tokio::test] async fn project_paths_resolve_relative_to_dot_codex_and_override_in_order() -> std::io::Result<()> { @@ -1490,18 +2008,13 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s .into_iter() .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); - assert_eq!( - vec![&ConfigLayerEntry { - name: ConfigLayerSource::Project { - dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_root.join(".codex"))?, - }, - config: TomlValue::Table(toml::map::Map::new()), - raw_toml: None, - version: version_for_toml(&TomlValue::Table(toml::map::Map::new())), - disabled_reason: None, - }], - project_layers + let expected_project_layer = ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_root.join(".codex"))?, + }, + TomlValue::Table(toml::map::Map::new()), ); + assert_eq!(vec![&expected_project_layer], project_layers); Ok(()) } @@ -1512,7 +2025,12 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R let home_dir = tmp.path().join("home"); let codex_home = home_dir.join(".codex"); tokio::fs::create_dir_all(&codex_home).await?; - tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), "foo = \"user\"\n").await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"foo = "user" +"#, + ) + .await?; let cwd = AbsolutePathBuf::from_absolute_path(&home_dir)?; let layers = load_config_layers_state( @@ -1554,7 +2072,12 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul tokio::fs::create_dir_all(&nested_dot_codex).await?; tokio::fs::create_dir_all(project_root.join(".git")).await?; - tokio::fs::write(nested_dot_codex.join(CONFIG_TOML_FILE), "foo = \"child\"\n").await?; + tokio::fs::write( + nested_dot_codex.join(CONFIG_TOML_FILE), + r#"foo = "child" +"#, + ) + .await?; tokio::fs::create_dir_all(&project_dot_codex).await?; make_config_for_test( @@ -1568,7 +2091,10 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul let user_config_contents = tokio::fs::read_to_string(&user_config_path).await?; tokio::fs::write( &user_config_path, - format!("foo = \"user\"\n{user_config_contents}"), + format!( + r#"foo = "user" +{user_config_contents}"# + ), ) .await?; @@ -1593,19 +2119,18 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); - let child_config: TomlValue = toml::from_str("foo = \"child\"\n").expect("parse child config"); - assert_eq!( - vec![&ConfigLayerEntry { - name: ConfigLayerSource::Project { - dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?, - }, - config: child_config.clone(), - raw_toml: None, - version: version_for_toml(&child_config), - disabled_reason: None, - }], - project_layers + let child_config: TomlValue = toml::from_str( + r#"foo = "child" +"#, + ) + .expect("parse child config"); + let expected_project_layer = ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?, + }, + child_config, ); + assert_eq!(vec![&expected_project_layer], project_layers); assert_eq!( layers.effective_config().get("foo"), Some(&TomlValue::String("child".to_string())) @@ -1622,7 +2147,9 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< tokio::fs::create_dir_all(nested.join(".codex")).await?; tokio::fs::write( nested.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"child\"\nprofile = \"ignored\"\n", + r#"foo = "child" +profile = "ignored" +"#, ) .await?; @@ -1641,7 +2168,10 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< let untrusted_config_contents = tokio::fs::read_to_string(&untrusted_config_path).await?; tokio::fs::write( &untrusted_config_path, - format!("foo = \"user\"\n{untrusted_config_contents}"), + format!( + r#"foo = "user" +{untrusted_config_contents}"# + ), ) .await?; @@ -1687,7 +2217,8 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< tokio::fs::create_dir_all(&codex_home_unknown).await?; tokio::fs::write( codex_home_unknown.join(CONFIG_TOML_FILE), - "foo = \"user\"\n", + r#"foo = "user" +"#, ) .await?; @@ -1857,7 +2388,8 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; tokio::fs::write( project_root.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"project\"\n", + r#"foo = "project" +"#, ) .await?; std::os::unix::fs::symlink(&project_root, &alias_root)?; @@ -2028,9 +2560,21 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: ) .await?; let config_contents = tokio::fs::read_to_string(&config_path).await?; - tokio::fs::write(&config_path, format!("foo = \"user\"\n{config_contents}")).await?; + tokio::fs::write( + &config_path, + format!( + r#"foo = "user" +{config_contents}"# + ), + ) + .await?; } else { - tokio::fs::write(&config_path, "foo = \"user\"\n").await?; + tokio::fs::write( + &config_path, + r#"foo = "user" +"#, + ) + .await?; } let layers = load_config_layers_state( @@ -2187,12 +2731,14 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() tokio::fs::write(project_root.join(".hg"), "hg").await?; tokio::fs::write( project_root.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"root\"\n", + r#"foo = "root" +"#, ) .await?; tokio::fs::write( nested.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"child\"\n", + r#"foo = "child" +"#, ) .await?; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 05ea84c308..35c4f3638e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7267,8 +7267,7 @@ model_verbosity = "high" /// 2. as part of a profile, where the `--profile` is specified via a CLI /// (or in the config file itself) /// 3. as an entry in `config.toml`, e.g. `model = "o3"` -/// 4. the default value for a required field defined in code, e.g., -/// `crate::flags::OPENAI_DEFAULT_MODEL` +/// 4. the default value for a required field defined in code. /// /// Note that profiles are the recommended way to specify a group of /// configuration options together. @@ -7340,6 +7339,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { startup_warnings: Vec::new(), history: History::default(), ephemeral: false, + bypass_hook_trust: false, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: None, codex_linux_sandbox_exe: None, @@ -7370,6 +7370,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { guardian_policy_config: None, include_permissions_instructions: true, include_apps_instructions: true, + include_collaboration_mode_instructions: true, include_skill_instructions: true, include_environment_context: true, compact_prompt: None, @@ -7786,6 +7787,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { startup_warnings: Vec::new(), history: History::default(), ephemeral: false, + bypass_hook_trust: false, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: None, codex_linux_sandbox_exe: None, @@ -7816,6 +7818,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { guardian_policy_config: None, include_permissions_instructions: true, include_apps_instructions: true, + include_collaboration_mode_instructions: true, include_skill_instructions: true, include_environment_context: true, compact_prompt: None, @@ -7946,6 +7949,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { startup_warnings: Vec::new(), history: History::default(), ephemeral: false, + bypass_hook_trust: false, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: None, codex_linux_sandbox_exe: None, @@ -7976,6 +7980,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { guardian_policy_config: None, include_permissions_instructions: true, include_apps_instructions: true, + include_collaboration_mode_instructions: true, include_skill_instructions: true, include_environment_context: true, compact_prompt: None, @@ -8091,6 +8096,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { startup_warnings: Vec::new(), history: History::default(), ephemeral: false, + bypass_hook_trust: false, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: None, codex_linux_sandbox_exe: None, @@ -8121,6 +8127,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { guardian_policy_config: None, include_permissions_instructions: true, include_apps_instructions: true, + include_collaboration_mode_instructions: true, include_skill_instructions: true, include_environment_context: true, compact_prompt: None, @@ -8180,6 +8187,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -8892,6 +8900,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -8985,6 +8994,29 @@ async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io Ok(()) } +#[tokio::test] +async fn bypass_hook_trust_adds_startup_warning() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .harness_overrides(ConfigOverrides { + bypass_hook_trust: Some(true), + ..Default::default() + }) + .build() + .await?; + + assert!( + config.startup_warnings.iter().any(|warning| warning + == "`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation."), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + #[tokio::test] async fn permission_profile_override_preserves_split_write_roots() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -9358,6 +9390,7 @@ async fn prompt_instruction_blocks_can_be_disabled_from_config_and_profiles() -> codex_home.path().join(CONFIG_TOML_FILE), r#"include_permissions_instructions = false include_apps_instructions = false +include_collaboration_mode_instructions = false include_environment_context = false profile = "chatty" @@ -9366,6 +9399,7 @@ include_instructions = false [profiles.chatty] include_permissions_instructions = true +include_collaboration_mode_instructions = true include_environment_context = true "#, )?; @@ -9378,6 +9412,7 @@ include_environment_context = true assert!(config.include_permissions_instructions); assert!(!config.include_apps_instructions); + assert!(config.include_collaboration_mode_instructions); assert!(!config.include_skill_instructions); assert!(config.include_environment_context); Ok(()) @@ -9638,6 +9673,7 @@ usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." hide_spawn_agent_metadata = true +non_code_mode_only = true "#, )?; @@ -9665,6 +9701,7 @@ hide_spawn_agent_metadata = true Some("Subagent guidance.") ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); + assert!(config.multi_agent_v2.non_code_mode_only); Ok(()) } @@ -9684,6 +9721,7 @@ usage_hint_text = "base hint" root_agent_usage_hint_text = "base root hint" subagent_usage_hint_text = "base subagent hint" hide_spawn_agent_metadata = true +non_code_mode_only = false [profiles.no_hint.features.multi_agent_v2] max_concurrent_threads_per_session = 6 @@ -9693,6 +9731,7 @@ usage_hint_text = "profile hint" root_agent_usage_hint_text = "profile root hint" subagent_usage_hint_text = "profile subagent hint" hide_spawn_agent_metadata = false +non_code_mode_only = true "#, )?; @@ -9718,6 +9757,7 @@ hide_spawn_agent_metadata = false Some("profile subagent hint") ); assert!(!config.multi_agent_v2.hide_spawn_agent_metadata); + assert!(config.multi_agent_v2.non_code_mode_only); Ok(()) } @@ -9741,6 +9781,7 @@ enabled = true assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4); assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000); assert_eq!(config.agent_max_threads, Some(3)); + assert!(!config.multi_agent_v2.non_code_mode_only); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f5dda3fc38..3b4e674446 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -15,7 +15,6 @@ use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; use codex_config::ConstrainedWithSource; use codex_config::FeatureRequirementsToml; -use codex_config::LoaderOverrides; use codex_config::McpServerIdentity; use codex_config::McpServerRequirement; use codex_config::PluginRequirementsToml; @@ -136,9 +135,11 @@ mod otel; mod permissions; #[cfg(test)] mod schema; +pub use codex_config::ConfigLoadOptions; pub use codex_config::Constrained; pub use codex_config::ConstraintError; pub use codex_config::ConstraintResult; +pub use codex_config::LoaderOverrides; pub use codex_network_proxy::NetworkProxyAuditMetadata; use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; pub use codex_sandboxing::system_bwrap_warning; @@ -465,6 +466,9 @@ pub struct Config { /// Whether to inject the `` developer block. pub include_apps_instructions: bool, + /// Whether to inject the `` developer block. + pub include_collaboration_mode_instructions: bool, + /// Whether to inject the `` developer block. pub include_skill_instructions: bool, @@ -528,8 +532,8 @@ pub struct Config { /// Controls whether the TUI uses the terminal's alternate screen buffer. /// /// This is the same `tui.alternate_screen` value from `config.toml`. - /// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere. - /// - `always`: Always use alternate screen (original behavior). + /// - `auto` (default): Use alternate screen. + /// - `always`: Always use alternate screen. /// - `never`: Never use alternate screen (inline mode, preserves scrollback). pub tui_alternate_screen: AltScreenMode, /// Ordered list of status line item identifiers for the TUI. @@ -665,6 +669,11 @@ pub struct Config { /// When true, session is not persisted on disk. Default to `false` pub ephemeral: bool, + /// Whether enabled hooks should run without requiring persisted hook trust for this session. + /// + /// This is a runtime-only knob populated from invocation overrides, not from config files. + pub bypass_hook_trust: bool, + /// Optional URI-based file opener. If set, citations to files in the model /// output will be hyperlinked using the specified URI scheme. pub file_opener: UriBasedFileOpener, @@ -837,6 +846,7 @@ pub struct MultiAgentV2Config { pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, pub hide_spawn_agent_metadata: bool, + pub non_code_mode_only: bool, } impl Default for MultiAgentV2Config { @@ -850,6 +860,7 @@ impl Default for MultiAgentV2Config { root_agent_usage_hint_text: None, subagent_usage_hint_text: None, hide_spawn_agent_metadata: false, + non_code_mode_only: false, } } } @@ -894,6 +905,7 @@ pub struct ConfigBuilder { cli_overrides: Option>, harness_overrides: Option, loader_overrides: Option, + strict_config: bool, cloud_requirements: CloudRequirementsLoader, thread_config_loader: Option>, fallback_cwd: Option, @@ -920,6 +932,11 @@ impl ConfigBuilder { self } + pub fn strict_config(mut self, strict_config: bool) -> Self { + self.strict_config = strict_config; + self + } + pub fn cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { self.cloud_requirements = cloud_requirements; self @@ -949,6 +966,7 @@ impl ConfigBuilder { cli_overrides, harness_overrides, loader_overrides, + strict_config, cloud_requirements, thread_config_loader, fallback_cwd, @@ -971,7 +989,10 @@ impl ConfigBuilder { &codex_home, Some(cwd), &cli_overrides, - loader_overrides, + ConfigLoadOptions { + loader_overrides, + strict_config, + }, cloud_requirements, thread_config_loader .as_deref() @@ -1252,56 +1273,38 @@ impl Config { ) .await } - - /// This is a secondary way of creating [Config], which is appropriate when - /// the harness is meant to be used with a specific configuration that - /// ignores user settings. For example, the `codex exec` subcommand is - /// designed to use [AskForApproval::Never] exclusively. - /// - /// Further, [ConfigOverrides] contains some options that are not supported - /// in [ConfigToml], such as `cwd`, `codex_self_exe`, `codex_linux_sandbox_exe`, and - /// `main_execve_wrapper_exe`. - pub async fn load_with_cli_overrides_and_harness_overrides( - cli_overrides: Vec<(String, TomlValue)>, - harness_overrides: ConfigOverrides, - ) -> std::io::Result { - ConfigBuilder::default() - .cli_overrides(cli_overrides) - .harness_overrides(harness_overrides) - .build() - .await - } -} - -/// DEPRECATED: Use [Config::load_with_cli_overrides()] instead because working -/// with [ConfigToml] directly means that [ConfigRequirements] have not been -/// applied yet, which risks failing to enforce required constraints. -pub async fn load_config_as_toml_with_cli_overrides( - codex_home: &Path, - cwd: Option<&AbsolutePathBuf>, - cli_overrides: Vec<(String, TomlValue)>, -) -> std::io::Result { - load_config_as_toml_with_cli_and_loader_overrides( - codex_home, - cwd, - cli_overrides, - LoaderOverrides::default(), - ) - .await } +/// DEPRECATED for most callers: prefer [Config::load_with_cli_overrides()] or +/// [ConfigBuilder] because working with [ConfigToml] directly means +/// [ConfigRequirements] have not been applied yet, which risks skipping +/// required constraints. pub async fn load_config_as_toml_with_cli_and_loader_overrides( codex_home: &Path, cwd: Option<&AbsolutePathBuf>, cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, +) -> std::io::Result { + load_config_as_toml_with_cli_and_load_options(codex_home, cwd, cli_overrides, loader_overrides) + .await +} + +/// DEPRECATED for most callers: prefer [Config::load_with_cli_overrides()] or +/// [ConfigBuilder] because working with [ConfigToml] directly means +/// [ConfigRequirements] have not been applied yet, which risks skipping +/// required constraints. +pub async fn load_config_as_toml_with_cli_and_load_options( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_overrides: Vec<(String, TomlValue)>, + options: impl Into, ) -> std::io::Result { let config_layer_stack = load_config_layers_state( LOCAL_FS.as_ref(), codex_home, cwd.cloned(), &cli_overrides, - loader_overrides, + options, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) @@ -1883,6 +1886,7 @@ pub struct ConfigOverrides { pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, pub ephemeral: Option, + pub bypass_hook_trust: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, } @@ -1993,6 +1997,10 @@ fn resolve_multi_agent_v2_config( .and_then(|config| config.hide_spawn_agent_metadata) .or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata)) .unwrap_or(default.hide_spawn_agent_metadata); + let non_code_mode_only = profile + .and_then(|config| config.non_code_mode_only) + .or_else(|| base.and_then(|config| config.non_code_mode_only)) + .unwrap_or(default.non_code_mode_only); MultiAgentV2Config { max_concurrent_threads_per_session, @@ -2002,6 +2010,7 @@ fn resolve_multi_agent_v2_config( root_agent_usage_hint_text, subagent_usage_hint_text, hide_spawn_agent_metadata, + non_code_mode_only, } } @@ -2122,6 +2131,7 @@ impl Config { approvals_reviewer: mut constrained_approvals_reviewer, permission_profile: mut constrained_permission_profile, web_search_mode: mut constrained_web_search_mode, + allow_managed_hooks_only: _, feature_requirements, managed_hooks: _, mcp_servers, @@ -2165,8 +2175,17 @@ impl Config { show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, ephemeral, + bypass_hook_trust, additional_writable_roots, } = overrides; + let bypass_hook_trust = bypass_hook_trust.unwrap_or_default(); + + if bypass_hook_trust { + startup_warnings.push( + "`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation." + .to_string(), + ); + } if sandbox_mode.is_some() && permission_profile.is_some() { return Err(std::io::Error::new( @@ -2816,6 +2835,10 @@ impl Config { .include_apps_instructions .or(cfg.include_apps_instructions) .unwrap_or(true); + let include_collaboration_mode_instructions = config_profile + .include_collaboration_mode_instructions + .or(cfg.include_collaboration_mode_instructions) + .unwrap_or(true); let include_skill_instructions = cfg .skills .as_ref() @@ -3031,6 +3054,7 @@ impl Config { commit_attribution, include_permissions_instructions, include_apps_instructions, + include_collaboration_mode_instructions, include_skill_instructions, include_environment_context, // The config.toml omits "_mode" because it's a config file. However, "_mode" @@ -3094,6 +3118,7 @@ impl Config { config_layer_stack, history, ephemeral: ephemeral.unwrap_or_default(), + bypass_hook_trust, file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), codex_self_exe, codex_linux_sandbox_exe, diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 1bc2cb0895..0cdbc0b010 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -73,6 +73,10 @@ fn build_collaboration_mode_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, ) -> Option { + if !next.config.include_collaboration_mode_instructions { + return None; + } + let prev = previous?; if prev.collaboration_mode.as_ref() != Some(&next.collaboration_mode) { // If the next mode has empty developer instructions, this returns None and we emit no diff --git a/codex-rs/core/src/flags.rs b/codex-rs/core/src/flags.rs deleted file mode 100644 index de693868df..0000000000 --- a/codex-rs/core/src/flags.rs +++ /dev/null @@ -1,6 +0,0 @@ -use env_flags::env_flags; - -env_flags! { - /// Fixture path for offline tests (see client.rs). - pub CODEX_RS_SSE_FIXTURE: Option<&str> = None; -} diff --git a/codex-rs/core/src/function_tool.rs b/codex-rs/core/src/function_tool.rs index 240e04361c..8686364855 100644 --- a/codex-rs/core/src/function_tool.rs +++ b/codex-rs/core/src/function_tool.rs @@ -1,11 +1 @@ -use thiserror::Error; - -#[derive(Debug, Error, PartialEq)] -pub enum FunctionCallError { - #[error("{0}")] - RespondToModel(String), - #[error("LocalShellCall without call_id or id")] - MissingLocalShellCallId, - #[error("Fatal error: {0}")] - Fatal(String), -} +pub use codex_tools::FunctionCallError; diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index ba116ec0e8..131433208a 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -40,7 +40,6 @@ pub(crate) use review::routes_approval_to_guardian; pub(crate) use review::spawn_approval_request_review; pub(crate) use review_session::GuardianReviewSessionManager; -const GUARDIAN_PREFERRED_MODEL: &str = "codex-auto-review"; pub(crate) const GUARDIAN_REVIEW_TIMEOUT: Duration = Duration::from_secs(90); pub(crate) const GUARDIAN_REVIEWER_NAME: &str = "guardian"; pub(crate) const MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN: u32 = 3; diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 3e88f9b08f..2908492ddd 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -659,9 +659,10 @@ pub(super) async fn run_guardian_review_session( fallback } }; + let preferred_model_id = turn.provider.approval_review_preferred_model(); let preferred_model = available_models .iter() - .find(|preset| preset.model == super::GUARDIAN_PREFERRED_MODEL); + .find(|preset| preset.model == preferred_model_id); let (guardian_model, guardian_reasoning_effort) = if let Some(preset) = preferred_model { let reasoning_effort = preferred_reasoning_effort( preset @@ -670,10 +671,7 @@ pub(super) async fn run_guardian_review_session( .any(|effort| effort.effort == codex_protocol::openai_models::ReasoningEffort::Low), Some(preset.default_reasoning_effort), ); - ( - super::GUARDIAN_PREFERRED_MODEL.to_string(), - reasoning_effort, - ) + (preferred_model_id.to_string(), reasoning_effort) } else { let reasoning_effort = preferred_reasoning_effort( turn.model_info diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index e647796cbf..7601ef44c3 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -22,6 +22,9 @@ use codex_config::types::McpServerConfig; use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_model_provider::create_model_provider; +use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_4_MODEL_ID; +use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; +use codex_model_provider_info::ModelProviderInfo; use codex_network_proxy::NetworkProxyConfig; use codex_protocol::ThreadId; use codex_protocol::approvals::NetworkApprovalProtocol; @@ -29,6 +32,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -2336,6 +2340,35 @@ async fn guardian_review_session_config_uses_parent_active_model_instead_of_hard assert_eq!(guardian_config.model, Some("active-model".to_string())); } +#[tokio::test] +async fn guardian_review_session_config_keeps_bedrock_provider_for_bedrock_gpt_5_4() { + let mut parent_config = test_config().await; + parent_config.model_provider_id = AMAZON_BEDROCK_PROVIDER_ID.to_string(); + parent_config.model_provider = + ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None); + + let guardian_config = build_guardian_review_session_config_for_test( + &parent_config, + /*live_network_config*/ None, + AMAZON_BEDROCK_GPT_5_4_MODEL_ID, + Some(ReasoningEffort::Low), + ) + .expect("guardian config"); + + assert_eq!( + ( + guardian_config.model, + guardian_config.model_provider_id, + guardian_config.model_provider, + ), + ( + Some(AMAZON_BEDROCK_GPT_5_4_MODEL_ID.to_string()), + AMAZON_BEDROCK_PROVIDER_ID.to_string(), + ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None), + ) + ); +} + #[tokio::test] async fn guardian_review_session_config_uses_requirements_guardian_policy_config() { let codex_home = tempfile::tempdir().expect("create temp dir"); diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 175813a544..3d72e4a7c6 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -115,7 +115,7 @@ pub(crate) async fn run_pending_session_start_hooks( }; let request = codex_hooks::SessionStartRequest { - session_id: sess.conversation_id, + session_id: sess.session_id().into(), cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -148,7 +148,7 @@ pub(crate) async fn run_pre_tool_use_hooks( tool_input: &Value, ) -> PreToolUseHookResult { let request = PreToolUseRequest { - session_id: sess.conversation_id, + session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -207,7 +207,7 @@ pub(crate) async fn run_permission_request_hooks( payload: PermissionRequestPayload, ) -> Option { let request = PermissionRequestRequest { - session_id: sess.conversation_id, + session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, @@ -247,7 +247,7 @@ pub(crate) async fn run_post_tool_use_hooks( tool_response: Value, ) -> PostToolUseOutcome { let request = PostToolUseRequest { - session_id: sess.conversation_id, + session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -274,7 +274,7 @@ pub(crate) async fn run_pre_compact_hooks( trigger: CompactionTrigger, ) -> PreCompactHookOutcome { let request = codex_hooks::PreCompactRequest { - session_id: sess.conversation_id, + session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -311,7 +311,7 @@ pub(crate) async fn run_post_compact_hooks( trigger: CompactionTrigger, ) -> PostCompactHookOutcome { let request = codex_hooks::PostCompactRequest { - session_id: sess.conversation_id, + session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -336,7 +336,7 @@ pub(crate) async fn run_user_prompt_submit_hooks( prompt: String, ) -> HookRuntimeOutcome { let request = UserPromptSubmitRequest { - session_id: sess.conversation_id, + session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 3ec34694cb..325d65745f 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -35,7 +35,6 @@ mod environment_selection; pub mod exec; pub mod exec_env; mod exec_policy; -mod flags; #[cfg(test)] mod git_info_tests; mod goals; @@ -145,7 +144,6 @@ mod tools; pub(crate) mod turn_diff_tracker; mod turn_metadata; mod turn_timing; -mod unavailable_tool; pub use rollout::ARCHIVED_SESSIONS_SUBDIR; pub use rollout::Cursor; pub use rollout::EventPersistenceMode; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index fe9e6ec73e..b82a7ea360 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -595,6 +595,11 @@ async fn execute_mcp_tool_call( ) .await .map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?; + let mcp_call_trace = sess + .services + .rollout_thread_trace + .start_mcp_call_trace(call_id); + let request_meta = mcp_call_trace.add_request_meta(request_meta); let result = sess .call_tool( &invocation.server, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 4272281552..4dd9dfa7d0 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -24,6 +24,13 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GranularApprovalConfig; +use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::SessionSource; +use codex_rollout_trace::ThreadStartedTraceMetadata; +use codex_rollout_trace::ToolDispatchInvocation; +use codex_rollout_trace::ToolDispatchPayload; +use codex_rollout_trace::ToolDispatchRequester; +use codex_rollout_trace::replay_bundle; use core_test_support::PathExt; use core_test_support::hooks::trusted_config_layer_stack; use core_test_support::responses::ev_assistant_message; @@ -35,6 +42,9 @@ use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; use serde::Deserialize; use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; use tempfile::tempdir; use tracing::Instrument; @@ -117,6 +127,60 @@ fn prompt_options( } } +#[tokio::test] +async fn execute_mcp_tool_call_records_replayable_correlation() -> anyhow::Result<()> { + let temp = tempdir()?; + let (mut session, turn_context) = make_session_and_context().await; + attach_trace_bundle(&mut session, &turn_context, temp.path())?; + + let dispatch_trace = session + .services + .rollout_thread_trace + .start_tool_dispatch_trace(|| { + Some(ToolDispatchInvocation { + thread_id: session.conversation_id.to_string(), + codex_turn_id: turn_context.sub_id.clone(), + tool_call_id: "mcp-call".to_string(), + tool_name: "search".to_string(), + tool_namespace: Some("mcp__docs__".to_string()), + requester: ToolDispatchRequester::Model { + model_visible_call_id: "mcp-call".to_string(), + }, + payload: ToolDispatchPayload::Function { + arguments: r#"{"query":"trace"}"#.to_string(), + }, + }) + }); + assert!(dispatch_trace.is_enabled()); + + let result = execute_mcp_tool_call( + &session, + &turn_context, + "mcp-call", + &McpInvocation { + server: "docs".to_string(), + tool: "search".to_string(), + arguments: Some(serde_json::json!({ "query": "trace" })), + }, + /*rewritten_arguments*/ None, + /*metadata*/ None, + /*request_meta*/ None, + ) + .await; + assert!( + result.is_err(), + "the synthetic backend is absent; only trace emission matters", + ); + + let replayed = replay_bundle(single_bundle_dir(temp.path())?)?; + assert!( + replayed.tool_calls["mcp-call"].mcp_call_id.is_some(), + "the real MCP execution path should emit a reducer-visible correlation ID", + ); + + Ok(()) +} + fn install_mcp_permission_request_hook( session: &mut Session, turn_context: &TurnContext, @@ -206,6 +270,45 @@ print({hook_output:?}) log_path.to_path_buf() } +/// Attaches a replayable rollout bundle to one synthetic session under test. +fn attach_trace_bundle( + session: &mut Session, + turn_context: &TurnContext, + root: &Path, +) -> anyhow::Result<()> { + let rollout_thread_trace = + codex_rollout_trace::ThreadTraceContext::start_root_in_root_for_test( + root, + ThreadStartedTraceMetadata { + thread_id: session.conversation_id.to_string(), + agent_path: "/root".to_string(), + task_name: None, + nickname: None, + agent_role: None, + session_source: SessionSource::Exec, + cwd: PathBuf::from("/workspace"), + rollout_path: None, + model: "gpt-test".to_string(), + provider_name: "test-provider".to_string(), + approval_policy: "never".to_string(), + sandbox_policy: "danger-full-access".to_string(), + }, + )?; + rollout_thread_trace.record_codex_turn_started(turn_context.sub_id.as_str()); + session.services.rollout_thread_trace = rollout_thread_trace; + Ok(()) +} + +/// Returns the sole bundle emitted under a temporary rollout trace root. +fn single_bundle_dir(root: &Path) -> anyhow::Result { + let mut entries = fs::read_dir(root)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>()?; + entries.sort(); + assert_eq!(entries.len(), 1); + Ok(entries.remove(0)) +} + #[test] fn mcp_app_resource_uri_reads_known_tool_meta_keys() { let nested = serde_json::json!({ @@ -2382,7 +2485,7 @@ async fn permission_request_hook_allows_mcp_tool_call() { assert_eq!( inputs, vec![serde_json::json!({ - "session_id": session.conversation_id, + "session_id": session.session_id(), "turn_id": "turn_id", "cwd": turn_context.cwd, "transcript_path": null, @@ -2442,7 +2545,7 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() { assert_eq!( inputs, vec![serde_json::json!({ - "session_id": session.conversation_id, + "session_id": session.session_id(), "turn_id": "turn_id", "cwd": turn_context.cwd, "transcript_path": null, diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index fd54044f21..bd856d5e6f 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -5,7 +5,6 @@ use crate::tools::handlers::unified_exec::ExecCommandArgs; use codex_memories_read::usage::MEMORIES_USAGE_METRIC; use codex_memories_read::usage::memories_usage_kinds_from_command; use codex_protocol::models::ShellCommandToolCallParams; -use codex_protocol::models::ShellToolCallParams; use std::path::PathBuf; pub(crate) async fn emit_metric_for_tool_read(invocation: &ToolInvocation, success: bool) { @@ -41,14 +40,6 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec serde_json::from_str::(arguments) - .ok() - .map(|params| { - ( - params.command, - invocation.turn.resolve_path(params.workdir).to_path_buf(), - ) - }), (None, "shell_command") => serde_json::from_str::(arguments) .ok() .map(|params| { diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 3e8c36734d..18748b33f1 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -133,6 +133,8 @@ fn save_config_resolved_fields( lock_config.model_verbosity = config.model_verbosity; lock_config.include_permissions_instructions = Some(config.include_permissions_instructions); lock_config.include_apps_instructions = Some(config.include_apps_instructions); + lock_config.include_collaboration_mode_instructions = + Some(config.include_collaboration_mode_instructions); lock_config.include_environment_context = Some(config.include_environment_context); lock_config.background_terminal_max_timeout = Some(config.background_terminal_max_timeout); diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 03f29bac53..ab40cb8eed 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -617,7 +617,7 @@ pub async fn set_thread_memory_mode(sess: &Arc, sub_id: String, mode: T } } -pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { +async fn shutdown_session_runtime(sess: &Arc) { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; let _ = sess.conversation.shutdown().await; sess.services @@ -630,6 +630,19 @@ pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { }; mcp_shutdown.await; sess.guardian_review_session.shutdown().await; +} + +fn emit_thread_stop_lifecycle(sess: &Session) { + for contributor in sess.services.extensions.thread_lifecycle_contributors() { + contributor.on_thread_stop(codex_extension_api::ThreadStopInput { + session_store: &sess.services.session_extension_data, + thread_store: &sess.services.thread_extension_data, + }); + } +} + +pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { + shutdown_session_runtime(sess).await; info!("Shutting down Codex instance"); let history = sess.clone_history().await; let turn_count = history @@ -643,6 +656,8 @@ pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { &[], ); + emit_thread_stop_lifecycle(sess.as_ref()); + // Gracefully flush and shutdown thread persistence on session end so tests // that inspect durable state do not race with the background writer. if let Some(live_thread) = sess.live_thread() @@ -714,6 +729,7 @@ pub(super) async fn submission_loop( rx_sub: Receiver, ) { // To break out of this loop, send Op::Shutdown. + let mut shutdown_received = false; while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); let dispatch_span = submission_dispatch_span(&sub); @@ -886,23 +902,16 @@ pub(super) async fn submission_loop( .instrument(dispatch_span) .await; if should_exit { + shutdown_received = true; break; } } // If the submission loop exits because the channel closed without an - // explicit shutdown op, still run process teardown for child processes - // owned by this session. - sess.services - .unified_exec_manager - .terminate_all_processes() - .await; - let mcp_shutdown = { - let mut manager = sess.services.mcp_connection_manager.write().await; - manager.begin_shutdown() - }; - mcp_shutdown.await; - // Also drain cached guardian state on this implicit shutdown path. - sess.guardian_review_session.shutdown().await; + // explicit shutdown op, still run session teardown. + if !shutdown_received { + shutdown_session_runtime(&sess).await; + emit_thread_stop_lifecycle(sess.as_ref()); + } debug!("Agent loop exited"); } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ea0196a23a..3ef9466812 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -358,7 +358,6 @@ use codex_tools::ToolEnvironmentMode; use codex_tools::ToolsConfig; use codex_tools::ToolsConfigParams; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_readiness::ReadinessFlag; #[cfg(test)] use codex_utils_stream_parser::ProposedPlanSegment; @@ -1318,7 +1317,16 @@ impl Session { &self, updates: SessionSettingsUpdate, ) -> ConstraintResult<()> { - let (previous_cwd, permission_profile_changed, next_cwd, codex_home, session_source) = { + let notify_config_contributors = !self.services.extensions.config_contributors().is_empty(); + let ( + previous_config, + new_config, + previous_cwd, + permission_profile_changed, + next_cwd, + codex_home, + session_source, + ) = { let mut state = self.state.lock().await; let updated = match state.session_configuration.apply(&updates) { Ok(updated) => updated, @@ -1328,6 +1336,10 @@ impl Session { } }; + let previous_config = notify_config_contributors + .then(|| Self::build_effective_session_config(&state.session_configuration)); + let new_config = + notify_config_contributors.then(|| Self::build_effective_session_config(&updated)); let previous_cwd = state.session_configuration.cwd.clone(); let previous_permission_profile = state.session_configuration.permission_profile(); let updated_permission_profile = updated.permission_profile(); @@ -1338,6 +1350,8 @@ impl Session { let session_source = updated.session_source.clone(); state.session_configuration = updated; ( + previous_config, + new_config, previous_cwd, permission_profile_changed, next_cwd, @@ -1346,6 +1360,7 @@ impl Session { ) }; + self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref()); self.maybe_refresh_shell_snapshot_for_cwd( &previous_cwd, &next_cwd, @@ -1398,8 +1413,11 @@ impl Session { // Refresh only the user layer from the incoming snapshot. Preserve thread-local // layers such as request/session overrides that were present when this session // was created. - let config = { + let notify_config_contributors = !self.services.extensions.config_contributors().is_empty(); + let (previous_config, new_config, config) = { let mut state = self.state.lock().await; + let previous_config = notify_config_contributors + .then(|| Self::build_effective_session_config(&state.session_configuration)); let mut config = (*state.session_configuration.original_config_do_not_use).clone(); config.config_layer_stack = config .config_layer_stack @@ -1408,8 +1426,11 @@ impl Session { resolve_tool_suggest_config_from_layer_stack(&config.config_layer_stack); let config = Arc::new(config); state.session_configuration.original_config_do_not_use = Arc::clone(&config); - config + let new_config = notify_config_contributors + .then(|| Self::build_effective_session_config(&state.session_configuration)); + (previous_config, new_config, config) }; + self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref()); self.services.skills_manager.clear_cache(); self.services.plugins_manager.clear_cache(); let hooks = build_hooks_for_config( @@ -1430,6 +1451,27 @@ impl Session { } } + fn emit_config_changed_contributors( + &self, + previous_config: Option<&Config>, + new_config: Option<&Config>, + ) { + let (Some(previous_config), Some(new_config)) = (previous_config, new_config) else { + return; + }; + if previous_config == new_config { + return; + } + for contributor in self.services.extensions.config_contributors() { + contributor.on_config_changed( + &self.services.session_extension_data, + &self.services.thread_extension_data, + previous_config, + new_config, + ); + } + } + pub(crate) async fn reload_user_config_layer(&self) { // Refresh layer-backed runtime state for an existing session, including enabled plugin, // skill, and hook state. Derived config fields such as feature gates and legacy notify @@ -2618,8 +2660,9 @@ impl Session { developer_sections.push(memory_prompt); } // Add developer instructions from collaboration_mode if they exist and are non-empty - if let Some(collab_instructions) = - CollaborationModeInstructions::from_collaboration_mode(&collaboration_mode) + if turn_context.config.include_collaboration_mode_instructions + && let Some(collab_instructions) = + CollaborationModeInstructions::from_collaboration_mode(&collaboration_mode) { developer_sections.push(collab_instructions.render()); } @@ -2694,11 +2737,15 @@ impl Session { { developer_sections.push(plugin_instructions.render()); } - for contributor in self.services.extensions.context_contributors() { - for fragment in contributor.contribute( - &self.services.session_extension_data, - &self.services.thread_extension_data, - ) { + let context_contributors = self.services.extensions.context_contributors().to_vec(); + for contributor in context_contributors { + for fragment in contributor + .contribute( + &self.services.session_extension_data, + &self.services.thread_extension_data, + ) + .await + { match fragment.slot() { PromptSlot::DeveloperPolicy | PromptSlot::DeveloperCapabilities => { developer_sections.push(fragment.text().to_string()); @@ -2847,13 +2894,36 @@ impl Session { turn_context: &TurnContext, token_usage: Option<&TokenUsage>, ) { - if let Some(token_usage) = token_usage { - let mut state = self.state.lock().await; - state.update_token_info_from_usage(token_usage, turn_context.model_context_window()); - } + self.record_token_usage_info(turn_context, token_usage) + .await; self.send_token_count_event(turn_context).await; } + pub(crate) async fn record_token_usage_info( + &self, + turn_context: &TurnContext, + token_usage: Option<&TokenUsage>, + ) { + if let Some(token_usage) = token_usage { + let token_info = { + let mut state = self.state.lock().await; + state + .update_token_info_from_usage(token_usage, turn_context.model_context_window()); + state.token_info() + }; + if let Some(token_info) = token_info.as_ref() { + for contributor in self.services.extensions.token_usage_contributors() { + contributor.on_token_usage( + &self.services.session_extension_data, + &self.services.thread_extension_data, + turn_context.extension_data.as_ref(), + token_info, + ); + } + } + } + } + pub(crate) async fn recompute_token_usage(&self, turn_context: &TurnContext) { let history = self.clone_history().await; let base_instructions = self.get_base_instructions().await; @@ -2892,11 +2962,15 @@ impl Session { turn_context: &TurnContext, new_rate_limits: RateLimitSnapshot, ) { + self.record_rate_limits_info(new_rate_limits).await; + self.send_token_count_event(turn_context).await; + } + + pub(crate) async fn record_rate_limits_info(&self, new_rate_limits: RateLimitSnapshot) { { let mut state = self.state.lock().await; state.set_rate_limits(new_rate_limits); } - self.send_token_count_event(turn_context).await; } pub(crate) async fn mcp_dependency_prompted(&self) -> HashSet { @@ -2927,7 +3001,7 @@ impl Session { state.set_server_reasoning_included(included); } - async fn send_token_count_event(&self, turn_context: &TurnContext) { + pub(crate) async fn send_token_count_event(&self, turn_context: &TurnContext) { let (info, rate_limits) = { let state = self.state.lock().await; state.token_info_and_rate_limits() @@ -3349,6 +3423,7 @@ async fn build_hooks_for_config( Hooks::new(HooksConfig { legacy_notify_argv: config.notify.clone(), feature_enabled: config.features.enabled(Feature::CodexHooks), + bypass_hook_trust: config.bypass_hook_trust, config_layer_stack: Some(config.config_layer_stack.clone()), plugin_hook_sources, plugin_hook_load_warnings, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 7d4b1b736a..083aac8c04 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -56,6 +56,7 @@ pub(super) async fn spawn_review_thread( .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) + .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session(config.agent_max_threads) .with_wait_agent_min_timeout_ms( @@ -113,7 +114,7 @@ pub(super) async fn spawn_review_thread( )); let review_turn_context = TurnContext { - sub_id: review_turn_id, + sub_id: review_turn_id.clone(), trace_id: current_span_trace_id(), realtime_active: parent_turn_context.realtime_active, config: per_turn_config, @@ -146,10 +147,10 @@ pub(super) async fn spawn_review_thread( final_output_json_schema: None, codex_self_exe: parent_turn_context.codex_self_exe.clone(), codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), - tool_call_gate: Arc::new(ReadinessFlag::new()), dynamic_tools: parent_turn_context.dynamic_tools.clone(), truncation_policy: model_info.truncation_policy.into(), turn_metadata_state, + extension_data: Arc::new(codex_extension_api::ExtensionData::new(review_turn_id)), turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()), turn_timing_state: Arc::new(TurnTimingState::default()), server_model_warning_emitted: AtomicBool::new(false), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 747a6f5b4f..5b1b8c83e4 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -811,15 +811,16 @@ impl Session { SessionId::from(thread_id) }; let agent_control = agent_control.with_session_id(session_id); - let session_extension_data = codex_extension_api::ExtensionData::new(); - let thread_extension_data = codex_extension_api::ExtensionData::new(); - for contributor in extensions.thread_start_contributors() { - contributor.contribute( - thread_id, - config.as_ref(), - &session_extension_data, - &thread_extension_data, - ); + let session_extension_data = + codex_extension_api::ExtensionData::new(session_id.to_string()); + let thread_extension_data = + codex_extension_api::ExtensionData::new(thread_id.to_string()); + for contributor in extensions.thread_lifecycle_contributors() { + contributor.on_thread_start(codex_extension_api::ThreadStartInput { + config: config.as_ref(), + session_store: &session_extension_data, + thread_store: &thread_extension_data, + }); } let services = SessionServices { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 705139cfd8..9ef67bcb8e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4,7 +4,6 @@ use crate::config::ConfigBuilder; use crate::config::test_config; use crate::context::ContextualUserFragment; use crate::context::TurnAborted; -use crate::exec::ExecCapturePolicy; use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::skills::SkillRenderSideEffects; @@ -69,9 +68,9 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::ExecCommandHandler; -use crate::tools::handlers::ShellHandler; +use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::UpdateGoalHandler; -use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolExecutor; use crate::tools::router::ToolCallSource; use crate::turn_diff_tracker::TurnDiffTracker; use codex_app_server_protocol::AppInfo; @@ -542,9 +541,8 @@ fn test_tool_runtime(session: Arc, turn_context: Arc) -> T crate::tools::router::ToolRouterParams { mcp_tools: None, deferred_mcp_tools: None, - unavailable_called_tools: Vec::new(), discoverable_tools: None, - extension_tool_bundles: Vec::new(), + extension_tool_executors: Vec::new(), dynamic_tools: turn_context.dynamic_tools.as_slice(), }, )); @@ -1801,6 +1799,248 @@ async fn recompute_token_usage_updates_model_context_window() { assert_eq!(actual.model_context_window, Some(128_000)); } +#[tokio::test] +async fn record_token_usage_info_notifies_extension_contributors() { + struct SessionTokenUsageMarker; + struct ThreadTokenUsageMarker; + + #[derive(Debug, PartialEq, Eq)] + struct RecordedTokenUsage { + session_level_id: String, + thread_level_id: String, + turn_level_id: String, + token_usage: TokenUsageInfo, + saw_session_store: bool, + saw_thread_store: bool, + } + + struct TokenUsageRecorder { + records: Arc>>, + } + + impl codex_extension_api::TokenUsageContributor for TokenUsageRecorder { + fn on_token_usage( + &self, + session_store: &codex_extension_api::ExtensionData, + thread_store: &codex_extension_api::ExtensionData, + turn_store: &codex_extension_api::ExtensionData, + token_usage: &TokenUsageInfo, + ) { + self.records + .lock() + .expect("token usage records lock") + .push(RecordedTokenUsage { + session_level_id: session_store.level_id().to_string(), + thread_level_id: thread_store.level_id().to_string(), + turn_level_id: turn_store.level_id().to_string(), + token_usage: token_usage.clone(), + saw_session_store: session_store.get::().is_some(), + saw_thread_store: thread_store.get::().is_some(), + }); + } + } + + let (mut session, turn_context) = make_session_and_context().await; + let records = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.token_usage_contributor(Arc::new(TokenUsageRecorder { + records: Arc::clone(&records), + })); + session.services.extensions = Arc::new(builder.build()); + session + .services + .session_extension_data + .insert(SessionTokenUsageMarker); + session + .services + .thread_extension_data + .insert(ThreadTokenUsageMarker); + + let first_usage = TokenUsage { + input_tokens: 10, + cached_input_tokens: 2, + output_tokens: 20, + reasoning_output_tokens: 3, + total_tokens: 33, + }; + let second_usage = TokenUsage { + input_tokens: 7, + cached_input_tokens: 1, + output_tokens: 8, + reasoning_output_tokens: 5, + total_tokens: 20, + }; + + session + .record_token_usage_info(&turn_context, Some(&first_usage)) + .await; + session + .record_token_usage_info(&turn_context, Some(&second_usage)) + .await; + + let mut expected_total_usage = first_usage.clone(); + expected_total_usage.add_assign(&second_usage); + let expected = vec![ + RecordedTokenUsage { + session_level_id: session.session_id().to_string(), + thread_level_id: session.conversation_id.to_string(), + turn_level_id: turn_context.sub_id.clone(), + token_usage: TokenUsageInfo { + total_token_usage: first_usage.clone(), + last_token_usage: first_usage, + model_context_window: turn_context.model_context_window(), + }, + saw_session_store: true, + saw_thread_store: true, + }, + RecordedTokenUsage { + session_level_id: session.session_id().to_string(), + thread_level_id: session.conversation_id.to_string(), + turn_level_id: turn_context.sub_id.clone(), + token_usage: TokenUsageInfo { + total_token_usage: expected_total_usage, + last_token_usage: second_usage, + model_context_window: turn_context.model_context_window(), + }, + saw_session_store: true, + saw_thread_store: true, + }, + ]; + let actual = records + .lock() + .expect("token usage records lock") + .drain(..) + .collect::>(); + assert_eq!(expected, actual); +} + +#[tokio::test] +async fn config_change_contributor_observes_effective_config_changes() { + struct SessionConfigMarker; + struct ThreadConfigMarker; + + #[derive(Debug, PartialEq)] + struct RecordedConfigChange { + previous_model: Option, + new_model: Option, + previous_disabled_tools: Vec, + new_disabled_tools: Vec, + saw_session_store: bool, + saw_thread_store: bool, + } + + struct ConfigRecorder { + records: Arc>>, + } + + impl codex_extension_api::ConfigContributor for ConfigRecorder { + fn on_config_changed( + &self, + session_store: &codex_extension_api::ExtensionData, + thread_store: &codex_extension_api::ExtensionData, + previous_config: &crate::config::Config, + new_config: &crate::config::Config, + ) { + self.records + .lock() + .expect("config change records lock") + .push(RecordedConfigChange { + previous_model: previous_config.model.clone(), + new_model: new_config.model.clone(), + previous_disabled_tools: previous_config.tool_suggest.disabled_tools.clone(), + new_disabled_tools: new_config.tool_suggest.disabled_tools.clone(), + saw_session_store: session_store.get::().is_some(), + saw_thread_store: thread_store.get::().is_some(), + }); + } + } + + let (mut session, _turn_context) = make_session_and_context().await; + let records = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.config_contributor(Arc::new(ConfigRecorder { + records: Arc::clone(&records), + })); + session.services.extensions = Arc::new(builder.build()); + session + .services + .session_extension_data + .insert(SessionConfigMarker); + session + .services + .thread_extension_data + .insert(ThreadConfigMarker); + + let original_model = session.collaboration_mode().await.model().to_string(); + let original_disabled_tools = session + .get_config() + .await + .tool_suggest + .disabled_tools + .clone(); + let next_model = if original_model == "gpt-5.4" { + "gpt-5.2" + } else { + "gpt-5.4" + }; + let collaboration_mode = session.collaboration_mode().await.with_updates( + Some(next_model.to_string()), + /*effort*/ None, + /*developer_instructions*/ None, + ); + session + .update_settings(SessionSettingsUpdate { + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }) + .await + .expect("update settings"); + + let codex_home = session.codex_home().await; + std::fs::create_dir_all(&codex_home).expect("create codex home"); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[tool_suggest] +disabled_tools = [ + { type = "connector", id = " calendar " }, + { type = "plugin", id = "slack@openai-curated" }, +] +"#, + ) + .expect("write user config"); + let next_config = load_latest_config_for_session(&session).await; + session.refresh_runtime_config(next_config).await; + + let expected_disabled_tools = vec![ + ToolSuggestDisabledTool::connector("calendar"), + ToolSuggestDisabledTool::plugin("slack@openai-curated"), + ]; + let expected = vec![ + RecordedConfigChange { + previous_model: Some(original_model), + new_model: Some(next_model.to_string()), + previous_disabled_tools: original_disabled_tools.clone(), + new_disabled_tools: original_disabled_tools.clone(), + saw_session_store: true, + saw_thread_store: true, + }, + RecordedConfigChange { + previous_model: Some(next_model.to_string()), + new_model: Some(next_model.to_string()), + previous_disabled_tools: original_disabled_tools, + new_disabled_tools: expected_disabled_tools, + saw_session_store: true, + saw_thread_store: true, + }, + ]; + let actual = records + .lock() + .expect("config change records lock") + .drain(..) + .collect::>(); + assert_eq!(expected, actual); +} + #[tokio::test] async fn record_initial_history_reconstructs_forked_transcript() { let (session, turn_context) = make_session_and_context().await; @@ -2837,10 +3077,6 @@ async fn turn_context_with_model_updates_model_fields() { updated.truncation_policy, expected_model_info.truncation_policy.into() ); - assert!(!Arc::ptr_eq( - &updated.tool_call_gate, - &turn_context.tool_call_gate - )); } #[test] @@ -3874,8 +4110,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { plugins_manager, mcp_manager, extensions: Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), - session_extension_data: codex_extension_api::ExtensionData::new(), - thread_extension_data: codex_extension_api::ExtensionData::new(), + session_extension_data: codex_extension_api::ExtensionData::new( + agent_control.session_id().to_string(), + ), + thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), agent_control, network_proxy: None, network_approval: Arc::clone(&network_approval), @@ -5201,6 +5439,125 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { ); } +#[tokio::test] +async fn submission_loop_channel_close_emits_thread_stop_lifecycle() { + struct SessionStopMarker; + struct ThreadStopMarker; + + struct ThreadStopRecorder { + calls: Arc, + expected_thread_id: ThreadId, + } + + impl codex_extension_api::ThreadLifecycleContributor for ThreadStopRecorder { + fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) { + assert_eq!( + self.expected_thread_id.to_string(), + input.thread_store.level_id() + ); + assert!(input.session_store.get::().is_some()); + assert!(input.thread_store.get::().is_some()); + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } + + let (mut session, turn_context) = make_session_and_context().await; + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.thread_lifecycle_contributor(Arc::new(ThreadStopRecorder { + calls: Arc::clone(&calls), + expected_thread_id: session.conversation_id, + })); + session.services.extensions = Arc::new(builder.build()); + session + .services + .session_extension_data + .insert(SessionStopMarker); + session + .services + .thread_extension_data + .insert(ThreadStopMarker); + + let (tx_sub, rx_sub) = async_channel::bounded(1); + drop(tx_sub); + let session = Arc::new(session); + submission_loop(session, Arc::clone(&turn_context.config), rx_sub).await; + + assert_eq!(1, calls.load(std::sync::atomic::Ordering::SeqCst)); +} + +#[tokio::test] +async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lifecycle() { + struct LifecycleRecorder { + calls: Arc>>, + expected_thread_id: ThreadId, + expected_turn_id: String, + } + + impl codex_extension_api::ThreadLifecycleContributor for LifecycleRecorder { + fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) { + assert_eq!( + self.expected_thread_id.to_string(), + input.thread_store.level_id() + ); + self.calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push("thread_stop"); + } + } + + impl codex_extension_api::TurnLifecycleContributor for LifecycleRecorder { + fn on_turn_abort(&self, input: codex_extension_api::TurnAbortInput<'_>) { + assert_eq!( + self.expected_thread_id.to_string(), + input.thread_store.level_id() + ); + assert_eq!(self.expected_turn_id, input.turn_store.level_id()); + assert_eq!(TurnAbortReason::Interrupted, input.reason); + self.calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push("turn_abort"); + } + } + + let (mut session, turn_context) = make_session_and_context().await; + let calls = Arc::new(std::sync::Mutex::new(Vec::new())); + let recorder = Arc::new(LifecycleRecorder { + calls: Arc::clone(&calls), + expected_thread_id: session.conversation_id, + expected_turn_id: turn_context.sub_id.clone(), + }); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.thread_lifecycle_contributor(recorder.clone()); + builder.turn_lifecycle_contributor(recorder); + session.services.extensions = Arc::new(builder.build()); + + let session = Arc::new(session); + session + .spawn_task( + Arc::new(turn_context), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, + ) + .await; + + let (tx_sub, rx_sub) = async_channel::bounded(1); + drop(tx_sub); + submission_loop(Arc::clone(&session), session.get_config().await, rx_sub).await; + + assert_eq!( + vec!["turn_abort", "thread_stop"], + *calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + ); +} + #[tokio::test] async fn shutdown_and_wait_allows_multiple_waiters() { let (session, _turn_context) = make_session_and_context().await; @@ -5594,8 +5951,10 @@ where plugins_manager, mcp_manager, extensions: Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), - session_extension_data: codex_extension_api::ExtensionData::new(), - thread_extension_data: codex_extension_api::ExtensionData::new(), + session_extension_data: codex_extension_api::ExtensionData::new( + agent_control.session_id().to_string(), + ), + thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), agent_control, network_proxy: None, network_approval: Arc::clone(&network_approval), @@ -6119,21 +6478,25 @@ struct GitAttributionTestContributor; struct GitAttributionTestState; impl codex_extension_api::ContextContributor for GitAttributionTestContributor { - fn contribute( - &self, - _session_store: &codex_extension_api::ExtensionData, - thread_store: &codex_extension_api::ExtensionData, - ) -> Vec { - thread_store - .get::() - .is_some() - .then(|| { - codex_extension_api::PromptFragment::developer_policy( - "git attribution extension enabled", - ) - }) - .into_iter() - .collect() + fn contribute<'a>( + &'a self, + _session_store: &'a codex_extension_api::ExtensionData, + thread_store: &'a codex_extension_api::ExtensionData, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async move { + thread_store + .get::() + .is_some() + .then(|| { + codex_extension_api::PromptFragment::developer_policy( + "git attribution extension enabled", + ) + }) + .into_iter() + .collect() + }) } } @@ -7953,7 +8316,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { turn_context: tc.as_ref(), - tool_name: "shell", + tool_name: "shell_command", }) .await?; @@ -8189,7 +8552,7 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow: .await; sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { turn_context: tc.as_ref(), - tool_name: "shell", + tool_name: "shell_command", }) .await?; @@ -8611,9 +8974,8 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { crate::tools::router::ToolRouterParams { deferred_mcp_tools, mcp_tools: Some(tools), - unavailable_called_tools: Vec::new(), discoverable_tools: None, - extension_tool_bundles: Vec::new(), + extension_tool_executors: Vec::new(), dynamic_tools: turn_context.dynamic_tools.as_slice(), }, ); @@ -8621,7 +8983,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { id: None, status: None, call_id: "call-1".to_string(), - name: "shell".to_string(), + name: "shell_command".to_string(), input: "{}".to_string(), }; @@ -8644,7 +9006,10 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { match err { FunctionCallError::Fatal(message) => { - assert_eq!(message, "tool shell invoked with incompatible payload"); + assert_eq!( + message, + "tool shell_command invoked with incompatible payload" + ); } other => panic!("expected FunctionCallError::Fatal, got {other:?}"), } @@ -8990,13 +9355,12 @@ async fn update_goal_tool_marks_goal_complete() { #[tokio::test] async fn rejects_escalated_permissions_when_policy_not_on_request() { - use crate::exec::ExecParams; use crate::exec_policy::ExecApprovalRequest; use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::protocol::AskForApproval; - use std::collections::HashMap; + use codex_tools::ShellCommandBackendConfig; let (session, mut turn_context_raw) = make_session_and_context().await; // Ensure policy is NOT OnRequest so the early rejection path triggers @@ -9007,43 +9371,16 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { let session = Arc::new(session); let mut turn_context = Arc::new(turn_context_raw); + let command_script = "echo hi"; let timeout_ms = 1000; let sandbox_permissions = SandboxPermissions::RequireEscalated; - let params = ExecParams { - command: if cfg!(windows) { - vec![ - "cmd.exe".to_string(), - "/C".to_string(), - "echo hi".to_string(), - ] - } else { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "echo hi".to_string(), - ] - }, - cwd: turn_context.cwd.clone(), - expiration: timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env: HashMap::new(), - network: None, - sandbox_permissions, - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: Some("test".to_string()), - arg0: None, - }; let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let tool_name = "shell"; + let tool_name = "shell_command"; let call_id = "test-call".to_string(); - let handler = ShellHandler::default(); + let handler = ShellCommandHandler::from(ShellCommandBackendConfig::Classic); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -9055,11 +9392,11 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { source: crate::tools::context::ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": params.command.clone(), + "command": command_script, "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), - "timeout_ms": params.expiration.timeout_ms(), - "sandbox_permissions": params.sandbox_permissions, - "justification": params.justification.clone(), + "timeout_ms": timeout_ms, + "sandbox_permissions": sandbox_permissions, + "justification": Some("test"), }) .to_string(), }, @@ -9085,11 +9422,14 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { turn_context_mut.permission_profile = PermissionProfile::Disabled; let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); + let command = session + .user_shell() + .derive_exec_args(command_script, turn_context.tools_config.allow_login_shell); let exec_approval_requirement = session .services .exec_policy .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: ¶ms.command, + command: &command, approval_policy: turn_context.approval_policy.value(), permission_profile: turn_context.permission_profile(), file_system_sandbox_policy: &file_system_sandbox_policy, diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 730b9f5e87..ffb0d94d80 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -1,8 +1,6 @@ use super::*; use crate::compact::InitialContextInjection; use crate::environment_selection::ResolvedTurnEnvironments; -use crate::exec::ExecCapturePolicy; -use crate::exec::ExecParams; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GUARDIAN_REVIEWER_NAME; use crate::sandboxing::SandboxPermissions; @@ -43,8 +41,6 @@ use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; -use serde::Deserialize; -use std::collections::HashMap; use std::fs; use std::sync::Arc; use std::time::Duration; @@ -238,7 +234,7 @@ async fn request_permissions_guardian_review_stops_when_cancelled() { } #[tokio::test] -async fn guardian_allows_shell_additional_permissions_requests_past_policy_validation() { +async fn guardian_allows_shell_command_additional_permissions_requests_past_policy_validation() { let server = start_mock_server().await; let _request_log = mount_sse_once( &server, @@ -292,38 +288,9 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid let turn_context = Arc::new(turn_context_raw); let expiration_ms: u64 = if cfg!(windows) { 2_500 } else { 1_000 }; - let params = ExecParams { - command: if cfg!(windows) { - vec![ - "cmd.exe".to_string(), - "/Q".to_string(), - "/D".to_string(), - "/C".to_string(), - "echo hi".to_string(), - ] - } else { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "echo hi".to_string(), - ] - }, - cwd: turn_context.cwd.clone(), - expiration: expiration_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env: HashMap::new(), - network: None, - sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: Some("test".to_string()), - arg0: None, - }; - - let handler = ShellHandler::default(); + let handler = crate::tools::handlers::ShellCommandHandler::from( + codex_tools::ShellCommandBackendConfig::Classic, + ); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -331,21 +298,22 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "test-call".to_string(), - tool_name: codex_tools::ToolName::plain("shell"), + tool_name: codex_tools::ToolName::plain("shell_command"), source: crate::tools::context::ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": params.command.clone(), + "command": "echo hi", + "login": false, "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), - "timeout_ms": params.expiration.timeout_ms(), - "sandbox_permissions": params.sandbox_permissions, + "timeout_ms": expiration_ms, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, "additional_permissions": PermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), }), file_system: None, }, - "justification": params.justification.clone(), + "justification": Some("test"), }) .to_string(), }, @@ -353,27 +321,11 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid .await; let output = expect_text_output(&resp.expect("expected Ok result")); - - #[derive(Deserialize, PartialEq, Eq, Debug)] - struct ResponseExecMetadata { - exit_code: i32, - } - - #[derive(Deserialize)] - struct ResponseExecOutput { - output: String, - metadata: ResponseExecMetadata, - } - - let exec_output: ResponseExecOutput = - serde_json::from_str(&output).expect("valid exec output json"); - - assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); - assert!(exec_output.output.contains("hi")); + assert!(output.contains("hi")); } #[tokio::test] -async fn strict_auto_review_turn_grant_forces_guardian_for_shell_policy_skip() { +async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_skip() { let server = start_mock_server().await; let guardian_request_log = mount_sse_once( &server, @@ -437,34 +389,22 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_policy_skip() { let session = Arc::new(session); let turn_context = Arc::new(turn_context_raw); - let handler = ShellHandler::default(); - let command = if cfg!(windows) { - vec![ - "cmd.exe".to_string(), - "/Q".to_string(), - "/D".to_string(), - "/C".to_string(), - "echo hi".to_string(), - ] - } else { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "echo hi".to_string(), - ] - }; + let handler = crate::tools::handlers::ShellCommandHandler::from( + codex_tools::ShellCommandBackendConfig::Classic, + ); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), - call_id: "strict-shell-call".to_string(), - tool_name: codex_tools::ToolName::plain("shell"), + call_id: "strict-shell-command-call".to_string(), + tool_name: codex_tools::ToolName::plain("shell_command"), source: ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": command, + "command": "echo hi", + "login": false, "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), "timeout_ms": 1_000_u64, }) @@ -593,7 +533,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message clippy::await_holding_invalid_type, reason = "test mutates active turn state directly to seed granted permissions" )] -async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_permissions_feature() { +async fn shell_command_allows_sticky_turn_permissions_without_inline_request_permissions_feature() { let (mut session, turn_context_raw) = make_session_and_context().await; session .features @@ -615,7 +555,9 @@ async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_per let session = Arc::new(session); let turn_context = Arc::new(turn_context_raw); - let handler = ShellHandler::default(); + let handler = crate::tools::handlers::ShellCommandHandler::from( + codex_tools::ShellCommandBackendConfig::Classic, + ); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -623,15 +565,12 @@ async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_per cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "sticky-turn-grant".to_string(), - tool_name: codex_tools::ToolName::plain("shell"), + tool_name: codex_tools::ToolName::plain("shell_command"), source: crate::tools::context::ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": [ - "/bin/sh", - "-c", - "echo hi", - ], + "command": "echo hi", + "login": false, "timeout_ms": 1_000_u64, "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), }) @@ -643,23 +582,7 @@ async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_per match resp { Ok(output) => { let output = expect_text_output(&output); - - #[derive(Deserialize, PartialEq, Eq, Debug)] - struct ResponseExecMetadata { - exit_code: i32, - } - - #[derive(Deserialize)] - struct ResponseExecOutput { - output: String, - metadata: ResponseExecMetadata, - } - - let exec_output: ResponseExecOutput = - serde_json::from_str(&output).expect("valid exec output json"); - - assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); - assert!(exec_output.output.contains("hi")); + assert!(output.contains("hi")); } Err(FunctionCallError::RespondToModel(output)) => { assert!( diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 71ea34da8c..2dd84213ed 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -55,10 +55,9 @@ use crate::tools::context::SharedTurnDiffTracker; use crate::tools::parallel::ToolCallRuntime; use crate::tools::registry::ToolArgumentDiffConsumer; use crate::tools::router::ToolRouterParams; -use crate::tools::router::extension_tool_bundles; +use crate::tools::router::extension_tool_executors; use crate::turn_diff_tracker::TurnDiffTracker; use crate::turn_timing::record_turn_ttft_metric; -use crate::unavailable_tool::collect_unavailable_called_tools; use crate::util::backoff; use crate::util::error_or_panic; use codex_analytics::AppInvocation; @@ -523,7 +522,7 @@ pub(crate) async fn run_turn( } .to_string(); let stop_request = codex_hooks::StopRequest { - session_id: sess.conversation_id, + session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -573,7 +572,7 @@ pub(crate) async fn run_turn( let hook_outcomes = sess .hooks() .dispatch(HookPayload { - session_id: sess.conversation_id, + session_id: sess.session_id().into(), cwd: turn_context.cwd.clone(), client: turn_context.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), @@ -1245,29 +1244,13 @@ pub(crate) async fn built_tools( ); let mcp_tools = has_mcp_servers.then_some(mcp_tool_exposure.direct_tools); let deferred_mcp_tools = mcp_tool_exposure.deferred_tools; - let unavailable_called_tools = if turn_context - .config - .features - .enabled(Feature::UnavailableDummyTools) - { - let exposed_tool_names = mcp_tools - .iter() - .chain(deferred_mcp_tools.iter()) - .flat_map(|tools| tools.iter().map(codex_mcp::ToolInfo::canonical_tool_name)) - .collect::>(); - collect_unavailable_called_tools(input, &exposed_tool_names) - } else { - Vec::new() - }; - Ok(Arc::new(ToolRouter::from_config( &turn_context.tools_config, ToolRouterParams { mcp_tools, deferred_mcp_tools, - unavailable_called_tools, discoverable_tools, - extension_tool_bundles: extension_tool_bundles(sess), + extension_tool_executors: extension_tool_executors(sess), dynamic_tools: turn_context.dynamic_tools.as_slice(), }, ))) @@ -1872,6 +1855,7 @@ async fn try_run_sampling_request( Box, )> = None; let mut should_emit_turn_diff = false; + let mut should_emit_token_count = 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); @@ -2098,7 +2082,8 @@ async fn try_run_sampling_request( ResponseEvent::RateLimits(snapshot) => { // Update internal state with latest rate limits, but defer sending until // token usage is available to avoid duplicate TokenCount events. - sess.update_rate_limits(&turn_context, snapshot).await; + sess.record_rate_limits_info(snapshot).await; + should_emit_token_count = true; } ResponseEvent::ModelsEtag(etag) => { // Update internal state with latest models etag @@ -2116,8 +2101,9 @@ async fn try_run_sampling_request( &mut assistant_message_stream_parsers, ) .await; - sess.update_token_usage_info(&turn_context, token_usage.as_ref()) + sess.record_token_usage_info(&turn_context, token_usage.as_ref()) .await; + should_emit_token_count = true; should_emit_turn_diff = true; if let Some(false) = end_turn { needs_follow_up = true; @@ -2245,6 +2231,14 @@ async fn try_run_sampling_request( drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; + if should_emit_token_count { + // A tool call such as request_user_input can intentionally pause the turn. Emit token + // counts only after pending tools resolve so clients do not see progress events while the + // turn is waiting on the user. This also needs to happen before returning cancellation so + // token usage already recorded from the completed response is still persisted. + sess.send_token_count_event(&turn_context).await; + } + if cancellation_token.is_cancelled() { return Err(CodexErr::TurnAborted); } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 5a8885e0f3..7b22ddcc39 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -89,10 +89,10 @@ pub struct TurnContext { pub(crate) final_output_json_schema: Option, pub(crate) codex_self_exe: Option, pub(crate) codex_linux_sandbox_exe: Option, - pub(crate) tool_call_gate: Arc, pub(crate) truncation_policy: TruncationPolicy, pub(crate) dynamic_tools: Vec, pub(crate) turn_metadata_state: Arc, + pub(crate) extension_data: Arc, pub(crate) turn_skills: TurnSkillsContext, pub(crate) turn_timing_state: Arc, pub(crate) server_model_warning_emitted: AtomicBool, @@ -218,6 +218,7 @@ impl TurnContext { .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) + .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(self.tools_config.goal_tools) .with_max_concurrent_threads_per_session( config @@ -272,10 +273,10 @@ impl TurnContext { final_output_json_schema: self.final_output_json_schema.clone(), codex_self_exe: self.codex_self_exe.clone(), codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), - tool_call_gate: Arc::new(ReadinessFlag::new()), truncation_policy, dynamic_tools: self.dynamic_tools.clone(), turn_metadata_state: self.turn_metadata_state.clone(), + extension_data: Arc::clone(&self.extension_data), turn_skills: self.turn_skills.clone(), turn_timing_state: Arc::clone(&self.turn_timing_state), server_model_warning_emitted: AtomicBool::new( @@ -438,6 +439,18 @@ impl Session { per_turn_config } + pub(crate) fn build_effective_session_config( + session_configuration: &SessionConfiguration, + ) -> Config { + let mut config = + Self::build_per_turn_config(session_configuration, session_configuration.cwd.clone()); + config.model = Some(session_configuration.collaboration_mode.model().to_string()); + config.permissions.approval_policy = session_configuration.approval_policy.clone(); + config.permissions.active_permission_profile = + session_configuration.active_permission_profile.clone(); + config + } + #[allow(clippy::too_many_arguments)] pub(crate) fn make_turn_context( thread_id: ThreadId, @@ -500,6 +513,7 @@ impl Session { .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) + .with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session( per_turn_config @@ -537,6 +551,7 @@ impl Session { network.is_some(), )); let (current_date, timezone) = local_time_context(); + let extension_data = Arc::new(codex_extension_api::ExtensionData::new(sub_id.clone())); TurnContext { sub_id, trace_id: current_span_trace_id(), @@ -571,10 +586,10 @@ impl Session { final_output_json_schema: None, codex_self_exe: per_turn_config.codex_self_exe.clone(), codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), - tool_call_gate: Arc::new(ReadinessFlag::new()), truncation_policy: model_info.truncation_policy.into(), dynamic_tools: session_configuration.dynamic_tools.clone(), turn_metadata_state, + extension_data, turn_skills: TurnSkillsContext::new(skills_outcome), turn_timing_state: Arc::new(TurnTimingState::default()), server_model_warning_emitted: AtomicBool::new(false), @@ -587,6 +602,7 @@ impl Session { sub_id: String, updates: SessionSettingsUpdate, ) -> CodexResult> { + let notify_config_contributors = !self.services.extensions.config_contributors().is_empty(); let update_result: CodexResult<_> = { let mut state = self.state.lock().await; match state.session_configuration.clone().apply(&updates) { @@ -611,6 +627,11 @@ impl Session { previous_permission_profile != next_permission_profile; let codex_home = next.codex_home.clone(); let session_source = next.session_source.clone(); + let previous_config = notify_config_contributors.then(|| { + Self::build_effective_session_config(&state.session_configuration) + }); + let new_config = notify_config_contributors + .then(|| Self::build_effective_session_config(&next)); state.session_configuration = next.clone(); Ok(( next, @@ -619,6 +640,8 @@ impl Session { previous_cwd, codex_home, session_source, + previous_config, + new_config, )) } Err(err) => Err(CodexErr::InvalidRequest(err.to_string())), @@ -632,6 +655,8 @@ impl Session { previous_cwd, codex_home, session_source, + previous_config, + new_config, ) = match update_result { Ok(update) => update, Err(err) => { @@ -648,6 +673,7 @@ impl Session { } }; + self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref()); self.maybe_refresh_shell_snapshot_for_cwd( &previous_cwd, &session_configuration.cwd, diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index b4f00ee864..25d7dada95 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -289,34 +289,6 @@ pub(crate) async fn handle_output_item_done( output.last_agent_message = last_agent_message; } - // Guardrail: the model issued a LocalShellCall without an id; surface the error back into history. - Err(FunctionCallError::MissingLocalShellCallId) => { - let msg = "LocalShellCall without call_id or id"; - ctx.turn_context - .session_telemetry - .log_tool_failed("local_shell", msg); - tracing::error!(msg); - - let response = ResponseInputItem::FunctionCallOutput { - call_id: String::new(), - output: FunctionCallOutputPayload { - body: FunctionCallOutputBody::Text(msg.to_string()), - ..Default::default() - }, - }; - record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item) - .await; - if let Some(response_item) = response_input_to_response_item(&response) { - ctx.sess - .record_conversation_items( - &ctx.turn_context, - std::slice::from_ref(&response_item), - ) - .await; - } - - output.needs_follow_up = true; - } // The tool request should be answered directly (or was denied); push that response into the transcript. Err(FunctionCallError::RespondToModel(message)) => { let response = ResponseInputItem::FunctionCallOutput { diff --git a/codex-rs/core/src/tasks/lifecycle.rs b/codex-rs/core/src/tasks/lifecycle.rs new file mode 100644 index 0000000000..2fb9017df2 --- /dev/null +++ b/codex-rs/core/src/tasks/lifecycle.rs @@ -0,0 +1,41 @@ +use codex_extension_api::ExtensionData; +use codex_protocol::protocol::TurnAbortReason; + +use crate::session::session::Session; + +impl Session { + pub(super) fn emit_turn_start_lifecycle(&self, turn_store: &ExtensionData) { + for contributor in self.services.extensions.turn_lifecycle_contributors() { + contributor.on_turn_start(codex_extension_api::TurnStartInput { + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store, + }); + } + } + + pub(super) fn emit_turn_stop_lifecycle(&self, turn_store: &ExtensionData) { + for contributor in self.services.extensions.turn_lifecycle_contributors() { + contributor.on_turn_stop(codex_extension_api::TurnStopInput { + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store, + }); + } + } + + pub(super) fn emit_turn_abort_lifecycle( + &self, + reason: TurnAbortReason, + turn_store: &ExtensionData, + ) { + for contributor in self.services.extensions.turn_lifecycle_contributors() { + contributor.on_turn_abort(codex_extension_api::TurnAbortInput { + reason: reason.clone(), + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store, + }); + } + } +} diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index bb7a79f58e..6da6d9ad9b 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -1,4 +1,5 @@ mod compact; +mod lifecycle; mod regular; mod review; mod user_shell; @@ -355,6 +356,7 @@ impl Session { turn_state.push_pending_input(item); } } + self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref()); let mut active = self.active_turn.lock().await; let turn = active.get_or_insert_with(ActiveTurn::default); @@ -488,6 +490,9 @@ impl Session { } } + if let Some(turn_context) = turn_context.as_deref() { + self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()); + } if (aborted_turn || reason == TurnAbortReason::Interrupted) && let Err(err) = self .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { @@ -533,6 +538,9 @@ impl Session { for task in tasks { self.handle_task_abort(task, reason.clone()).await; } + if let Some(turn_context) = turn_context.as_deref() { + self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()); + } if let Err(err) = self .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { turn_context: turn_context.as_deref(), @@ -733,6 +741,9 @@ impl Session { .turn_timing_state .time_to_first_token_ms() .await; + if should_clear_active_turn { + self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()); + } if let Err(err) = self .goal_runtime_apply(GoalRuntimeEvent::TurnFinished { turn_context: turn_context.as_ref(), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 27cc600213..7b3d0a9921 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -58,8 +58,10 @@ use codex_thread_store::LocalThreadStoreConfig; use codex_thread_store::ReadThreadByRolloutPathParams; use codex_thread_store::ReadThreadParams; use codex_thread_store::StoredThread; +use codex_thread_store::ThreadMetadataPatch; use codex_thread_store::ThreadStore; use codex_thread_store::ThreadStoreError; +use codex_thread_store::UpdateThreadMetadataParams; use codex_utils_absolute_path::AbsolutePathBuf; use futures::StreamExt; use futures::stream::FuturesUnordered; @@ -456,6 +458,44 @@ impl ThreadManager { self.state.get_thread(thread_id).await } + /// Updates metadata for loaded and cold threads through one entrypoint. + /// + /// Loaded threads route through `CodexThread`/`LiveThread`, so metadata changes stay ordered + /// with live rollout writes. Cold threads go directly to the store, which owns unloaded JSONL + /// compatibility and SQLite metadata updates. + pub async fn update_thread_metadata( + &self, + thread_id: ThreadId, + patch: ThreadMetadataPatch, + include_archived: bool, + ) -> CodexResult { + if let Ok(thread) = self.get_thread(thread_id).await { + if thread.config_snapshot().await.ephemeral { + return Err(CodexErr::InvalidRequest(format!( + "ephemeral thread does not support metadata updates: {thread_id}" + ))); + } + return thread + .update_thread_metadata(patch, include_archived) + .await + .map_err(|err| thread_store_metadata_update_error(thread_id, err)); + } + self.state + .thread_store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch, + include_archived, + }) + .await + .map_err(|err| match err { + ThreadStoreError::ThreadNotFound { thread_id } => { + CodexErr::ThreadNotFound(thread_id) + } + err => thread_store_metadata_update_error(thread_id, err), + }) + } + /// List `thread_id` plus all known descendants in its spawn subtree. pub async fn list_agent_subtree_thread_ids( &self, @@ -1187,10 +1227,11 @@ impl ThreadManagerState { let new_thread = self .finalize_thread_spawn(codex, thread_id, tracked_session_source) .await?; - if is_resumed_thread - && let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await - { - warn!("failed to apply goal resume runtime effects: {err}"); + if is_resumed_thread { + new_thread.thread.emit_thread_resume_lifecycle(); + if let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await { + warn!("failed to apply goal resume runtime effects: {err}"); + } } Ok(new_thread) } @@ -1298,6 +1339,19 @@ fn thread_store_rollout_read_error(err: ThreadStoreError) -> CodexErr { } } +fn thread_store_metadata_update_error(thread_id: ThreadId, err: ThreadStoreError) -> CodexErr { + match err { + ThreadStoreError::ThreadNotFound { thread_id } => CodexErr::ThreadNotFound(thread_id), + ThreadStoreError::InvalidRequest { message } => CodexErr::InvalidRequest(message), + ThreadStoreError::Unsupported { operation } => CodexErr::UnsupportedOperation(format!( + "thread metadata update is not supported by this store: {operation}" + )), + err => CodexErr::Fatal(format!( + "failed to update thread metadata {thread_id}: {err}" + )), + } +} + /// Return a fork snapshot cut strictly before the nth user message (0-based). /// /// Out-of-range values keep the full committed history at a turn boundary, but diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 80a72a88ec..eaa4150201 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -2,23 +2,27 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; use super::ExecContext; use super::PUBLIC_TOOL_NAME; -use super::build_enabled_tools; use super::handle_runtime_response; use super::is_exec_tool_name; pub struct CodeModeExecuteHandler { spec: ToolSpec, + nested_tool_specs: Vec, } impl CodeModeExecuteHandler { - pub(crate) fn new(spec: ToolSpec) -> Self { - Self { spec } + pub(crate) fn new(spec: ToolSpec, nested_tool_specs: Vec) -> Self { + Self { + spec, + nested_tool_specs, + } } async fn execute( @@ -31,7 +35,8 @@ impl CodeModeExecuteHandler { let args = codex_code_mode::parse_exec_source(&code).map_err(FunctionCallError::RespondToModel)?; let exec = ExecContext { session, turn }; - let enabled_tools = build_enabled_tools(&exec).await; + let enabled_tools = + codex_tools::collect_code_mode_tool_definitions(&self.nested_tool_specs); let stored_values = exec .session .services @@ -82,7 +87,7 @@ impl CodeModeExecuteHandler { } } -impl ToolHandler for CodeModeExecuteHandler { +impl ToolExecutor for CodeModeExecuteHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -93,10 +98,6 @@ impl ToolHandler for CodeModeExecuteHandler { Some(self.spec.clone()) } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Custom { .. }) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, @@ -117,3 +118,9 @@ impl ToolHandler for CodeModeExecuteHandler { } } } + +impl ToolHandler for CodeModeExecuteHandler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Custom { .. }) + } +} diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index 9ee8e0352a..ff9f8c8893 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::time::Duration; use codex_code_mode::CodeModeNestedToolCall; +use codex_code_mode::CodeModeToolKind; use codex_code_mode::CodeModeTurnHost; use codex_code_mode::RuntimeResponse; use codex_protocol::models::FunctionCallOutputContentItem; @@ -28,13 +29,9 @@ use crate::tools::context::ToolPayload; use crate::tools::parallel::ToolCallRuntime; use crate::tools::router::ToolCall; use crate::tools::router::ToolCallSource; -use crate::tools::router::ToolRouterParams; -use crate::tools::router::extension_tool_bundles; use crate::unified_exec::resolve_max_tokens; use codex_features::Feature; use codex_tools::ToolName; -use codex_tools::ToolSpec; -use codex_tools::collect_code_mode_tool_definitions; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy; use codex_utils_output_truncation::truncate_function_output_items_with_policy; @@ -260,36 +257,6 @@ fn truncate_code_mode_result( truncate_function_output_items_with_policy(&items, policy) } -pub(super) async fn build_enabled_tools( - exec: &ExecContext, -) -> Vec { - let router = build_nested_router(exec).await; - let specs = router.specs(); - collect_code_mode_tool_definitions(&specs) -} - -#[expect( - clippy::await_holding_invalid_type, - reason = "nested tool router construction reads through the session-owned manager guard" -)] -async fn build_nested_router(exec: &ExecContext) -> ToolRouter { - let nested_tools_config = exec.turn.tools_config.for_code_mode_nested_tools(); - let mcp_connection_manager = exec.session.services.mcp_connection_manager.read().await; - let listed_mcp_tools = mcp_connection_manager.list_all_tools().await; - - ToolRouter::from_config( - &nested_tools_config, - ToolRouterParams { - deferred_mcp_tools: None, - mcp_tools: Some(listed_mcp_tools), - unavailable_called_tools: Vec::new(), - discoverable_tools: None, - extension_tool_bundles: extension_tool_bundles(exec.session.as_ref()), - dynamic_tools: exec.turn.dynamic_tools.as_slice(), - }, - ) -} - async fn call_nested_tool( _exec: ExecContext, tool_runtime: ToolCallRuntime, @@ -300,6 +267,7 @@ async fn call_nested_tool( cell_id, runtime_tool_call_id, tool_name, + tool_kind, input, } = invocation; if is_exec_tool_name(&tool_name) { @@ -308,11 +276,10 @@ async fn call_nested_tool( ))); } - let payload = - match build_nested_tool_payload(tool_runtime.find_spec(&tool_name), &tool_name, input) { - Ok(payload) => payload, - Err(error) => return Err(FunctionCallError::RespondToModel(error)), - }; + let payload = match build_nested_tool_payload(tool_kind, &tool_name, input) { + Ok(payload) => payload, + Err(error) => return Err(FunctionCallError::RespondToModel(error)), + }; let call = ToolCall { tool_name, @@ -332,36 +299,14 @@ async fn call_nested_tool( Ok(result.code_mode_result()) } -fn tool_kind_for_spec(spec: &ToolSpec) -> codex_code_mode::CodeModeToolKind { - if matches!(spec, ToolSpec::Freeform(_)) { - codex_code_mode::CodeModeToolKind::Freeform - } else { - codex_code_mode::CodeModeToolKind::Function - } -} - -fn tool_kind_for_name( - spec: Option, - tool_name: &ToolName, -) -> Result { - spec.as_ref() - .map(tool_kind_for_spec) - .ok_or_else(|| format!("tool `{tool_name}` is not enabled in {PUBLIC_TOOL_NAME}")) -} - fn build_nested_tool_payload( - spec: Option, + tool_kind: CodeModeToolKind, tool_name: &ToolName, input: Option, ) -> Result { - let actual_kind = tool_kind_for_name(spec, tool_name)?; - match actual_kind { - codex_code_mode::CodeModeToolKind::Function => { - build_function_tool_payload(tool_name, input) - } - codex_code_mode::CodeModeToolKind::Freeform => { - build_freeform_tool_payload(tool_name, input) - } + match tool_kind { + CodeModeToolKind::Function => build_function_tool_payload(tool_name, input), + CodeModeToolKind::Freeform => build_freeform_tool_payload(tool_name, input), } } @@ -396,3 +341,46 @@ fn build_freeform_tool_payload( _ => Err(format!("tool `{tool_name}` expects a string input")), } } + +#[cfg(test)] +mod tests { + use super::build_nested_tool_payload; + use crate::tools::context::ToolPayload; + use codex_code_mode::CodeModeToolKind; + use codex_tools::ToolName; + use serde_json::json; + + #[test] + fn build_nested_tool_payload_uses_function_kind() { + let payload = build_nested_tool_payload( + CodeModeToolKind::Function, + &ToolName::plain("example"), + Some(json!({ "value": 1 })), + ) + .expect("function payload should serialize"); + + match payload { + ToolPayload::Function { arguments } => { + assert_eq!(arguments, r#"{"value":1}"#.to_string()); + } + other => panic!("expected function payload, got {other:?}"), + } + } + + #[test] + fn build_nested_tool_payload_uses_freeform_kind() { + let payload = build_nested_tool_payload( + CodeModeToolKind::Freeform, + &ToolName::plain("example"), + Some(json!("hello")), + ) + .expect("freeform payload should preserve string input"); + + match payload { + ToolPayload::Custom { input } => { + assert_eq!(input, "hello".to_string()); + } + other => panic!("expected freeform payload, got {other:?}"), + } + } +} diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index 40ca82939c..070b9270d5 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -4,6 +4,7 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -40,7 +41,7 @@ where }) } -impl ToolHandler for CodeModeWaitHandler { +impl ToolExecutor for CodeModeWaitHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -105,3 +106,5 @@ impl ToolHandler for CodeModeWaitHandler { } } } + +impl ToolHandler for CodeModeWaitHandler {} diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 76a99fe8fc..0f3ae631a0 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -8,13 +8,10 @@ use crate::tools::TELEMETRY_PREVIEW_TRUNCATION_NOTICE; use crate::turn_diff_tracker::TurnDiffTracker; use crate::unified_exec::resolve_max_tokens; use codex_protocol::mcp::CallToolResult; -use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; -use codex_protocol::models::SearchToolCallParams; -use codex_protocol::models::ShellToolCallParams; use codex_protocol::models::function_call_output_content_items_to_text; use codex_tools::LoadableToolSpec; use codex_tools::ToolName; @@ -23,12 +20,14 @@ use codex_utils_output_truncation::formatted_truncate_text; use codex_utils_string::take_bytes_at_char_boundary; use serde::Serialize; use serde_json::Value as JsonValue; -use std::borrow::Cow; use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; +pub use codex_tools::ToolOutput; +pub use codex_tools::ToolPayload; + pub type SharedTurnDiffTracker = Arc>; #[derive(Clone, Debug, Eq, PartialEq)] @@ -56,73 +55,6 @@ pub struct ToolInvocation { pub payload: ToolPayload, } -#[derive(Clone, Debug)] -pub enum ToolPayload { - Function { arguments: String }, - ToolSearch { arguments: SearchToolCallParams }, - Custom { input: String }, - LocalShell { params: ShellToolCallParams }, -} - -impl ToolPayload { - pub fn log_payload(&self) -> Cow<'_, str> { - match self { - ToolPayload::Function { arguments } => Cow::Borrowed(arguments), - ToolPayload::ToolSearch { arguments } => Cow::Owned(arguments.query.clone()), - ToolPayload::Custom { input } => Cow::Borrowed(input), - ToolPayload::LocalShell { params } => Cow::Owned(params.command.join(" ")), - } - } -} - -pub trait ToolOutput: Send { - fn log_preview(&self) -> String; - - fn success_for_logging(&self) -> bool; - - fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem; - - /// Returns the stable value exposed to `PostToolUse` hooks for this tool output. - /// - /// Tool handlers decide whether a tool participates in `PostToolUse`, but - /// this method lets the output type own any conversion from model-facing - /// response content to hook-facing data. Returning `None` means the output - /// should not produce a post-use hook payload, not merely that the tool had - /// empty output. - fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { - None - } - - fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue { - response_input_to_code_mode_result(self.to_response_item("", payload)) - } -} - -impl ToolOutput for CallToolResult { - fn log_preview(&self) -> String { - let output = self.as_function_call_output_payload(); - let preview = output.body.to_text().unwrap_or_else(|| output.to_string()); - telemetry_preview(&preview) - } - - fn success_for_logging(&self) -> bool { - self.success() - } - - fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { - ResponseInputItem::McpToolCallOutput { - call_id: call_id.to_string(), - output: self.clone(), - } - } - - fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { - serde_json::to_value(self).unwrap_or_else(|err| { - JsonValue::String(format!("failed to serialize mcp result: {err}")) - }) - } -} - #[derive(Clone, Debug)] pub struct McpToolOutput { pub result: CallToolResult, @@ -469,62 +401,6 @@ impl ExecCommandToolOutput { } } -pub(crate) fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue { - match response { - ResponseInputItem::Message { content, .. } => content_items_to_code_mode_result( - &content - .into_iter() - .map(|item| match item { - codex_protocol::models::ContentItem::InputText { text } - | codex_protocol::models::ContentItem::OutputText { text } => { - FunctionCallOutputContentItem::InputText { text } - } - codex_protocol::models::ContentItem::InputImage { image_url, detail } => { - FunctionCallOutputContentItem::InputImage { - image_url, - detail: detail.or(Some(DEFAULT_IMAGE_DETAIL)), - } - } - }) - .collect::>(), - ), - ResponseInputItem::FunctionCallOutput { output, .. } - | ResponseInputItem::CustomToolCallOutput { output, .. } => match output.body { - FunctionCallOutputBody::Text(text) => JsonValue::String(text), - FunctionCallOutputBody::ContentItems(items) => { - content_items_to_code_mode_result(&items) - } - }, - ResponseInputItem::ToolSearchOutput { tools, .. } => JsonValue::Array(tools), - ResponseInputItem::McpToolCallOutput { output, .. } => { - output.code_mode_result(&ToolPayload::Function { - arguments: String::new(), - }) - } - } -} - -fn content_items_to_code_mode_result(items: &[FunctionCallOutputContentItem]) -> JsonValue { - JsonValue::String( - items - .iter() - .filter_map(|item| match item { - FunctionCallOutputContentItem::InputText { text } if !text.trim().is_empty() => { - Some(text.clone()) - } - FunctionCallOutputContentItem::InputImage { image_url, .. } - if !image_url.trim().is_empty() => - { - Some(image_url.clone()) - } - FunctionCallOutputContentItem::InputText { .. } - | FunctionCallOutputContentItem::InputImage { .. } => None, - }) - .collect::>() - .join("\n"), - ) -} - fn function_tool_response( call_id: &str, payload: &ToolPayload, diff --git a/codex-rs/core/src/tools/context_tests.rs b/codex-rs/core/src/tools/context_tests.rs index 6dc4248313..fe40341e95 100644 --- a/codex-rs/core/src/tools/context_tests.rs +++ b/codex-rs/core/src/tools/context_tests.rs @@ -1,5 +1,6 @@ use super::*; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; +use codex_protocol::models::SearchToolCallParams; use core_test_support::assert_regex_match; use pretty_assertions::assert_eq; use serde_json::json; diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs b/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs index ad68155825..7797c8c61b 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs @@ -3,6 +3,7 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::agent_jobs_spec::create_report_agent_job_result_tool; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -11,7 +12,7 @@ use super::*; pub struct ReportAgentJobResultHandler; -impl ToolHandler for ReportAgentJobResultHandler { +impl ToolExecutor for ReportAgentJobResultHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -22,10 +23,6 @@ impl ToolHandler for ReportAgentJobResultHandler { Some(create_report_agent_job_result_tool()) } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, payload, .. @@ -44,6 +41,12 @@ impl ToolHandler for ReportAgentJobResultHandler { } } +impl ToolHandler for ReportAgentJobResultHandler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } +} + pub async fn handle( session: Arc, arguments: String, diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs index 183387abfb..6dd7ea2b31 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs @@ -3,6 +3,7 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::agent_jobs_spec::create_spawn_agents_on_csv_tool; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -11,7 +12,7 @@ use super::*; pub struct SpawnAgentsOnCsvHandler; -impl ToolHandler for SpawnAgentsOnCsvHandler { +impl ToolExecutor for SpawnAgentsOnCsvHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -22,10 +23,6 @@ impl ToolHandler for SpawnAgentsOnCsvHandler { Some(create_spawn_agents_on_csv_tool()) } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, @@ -47,6 +44,12 @@ impl ToolHandler for SpawnAgentsOnCsvHandler { } } +impl ToolHandler for SpawnAgentsOnCsvHandler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } +} + /// Create a new agent job from a CSV and run it to completion. /// /// Each CSV row becomes a job item. The instruction string is a template where `{column}` diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 85b2c979bb..32edd21da1 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -30,6 +30,7 @@ use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolArgumentDiffConsumer; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use crate::tools::runtimes::apply_patch::ApplyPatchRequest; use crate::tools::runtimes::apply_patch::ApplyPatchRuntime; @@ -296,7 +297,7 @@ async fn effective_patch_permissions( ) } -impl ToolHandler for ApplyPatchHandler { +impl ToolExecutor for ApplyPatchHandler { type Output = ApplyPatchToolOutput; fn tool_name(&self) -> ToolName { @@ -307,57 +308,6 @@ impl ToolHandler for ApplyPatchHandler { Some(create_apply_patch_freeform_tool(self.multi_environment)) } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Custom { .. }) - } - - async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { - true - } - - fn create_diff_consumer(&self) -> Option> { - Some(Box::::default()) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - apply_patch_payload_command(&invocation.payload).map(|command| PreToolUsePayload { - tool_name: HookToolName::apply_patch(), - tool_input: serde_json::json!({ "command": command }), - }) - } - - fn with_updated_hook_input( - &self, - mut invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - let patch = updated_hook_command(&updated_input)?; - invocation.payload = match invocation.payload { - ToolPayload::Custom { .. } => ToolPayload::Custom { - input: patch.to_string(), - }, - payload => payload, - }; - Ok(invocation) - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - let tool_response = - result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::apply_patch(), - tool_use_id: invocation.call_id.clone(), - tool_input: serde_json::json!({ - "command": apply_patch_payload_command(&invocation.payload)?, - }), - tool_response, - }) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, @@ -488,6 +438,55 @@ impl ToolHandler for ApplyPatchHandler { } } +impl ToolHandler for ApplyPatchHandler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Custom { .. }) + } + + fn create_diff_consumer(&self) -> Option> { + Some(Box::::default()) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + apply_patch_payload_command(&invocation.payload).map(|command| PreToolUsePayload { + tool_name: HookToolName::apply_patch(), + tool_input: serde_json::json!({ "command": command }), + }) + } + + fn with_updated_hook_input( + &self, + mut invocation: ToolInvocation, + updated_input: serde_json::Value, + ) -> Result { + let patch = updated_hook_command(&updated_input)?; + invocation.payload = match invocation.payload { + ToolPayload::Custom { .. } => ToolPayload::Custom { + input: patch.to_string(), + }, + payload => payload, + }; + Ok(invocation) + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + let tool_response = + result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + Some(PostToolUsePayload { + tool_name: HookToolName::apply_patch(), + tool_use_id: invocation.call_id.clone(), + tool_input: serde_json::json!({ + "command": apply_patch_payload_command(&invocation.payload)?, + }), + tool_response, + }) + } +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn intercept_apply_patch( command: &[String], diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 59450a837d..8000b2097c 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -5,14 +5,24 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolExecutor; +use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolHandler; +use crate::tools::tool_search_entry::ToolSearchInfo; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::dynamic_tools::DynamicToolCallRequest; use codex_protocol::dynamic_tools::DynamicToolResponse; +use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::protocol::DynamicToolCallResponseEvent; use codex_protocol::protocol::EventMsg; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolName; +use codex_tools::ToolSearchSourceInfo; +use codex_tools::ToolSpec; +use codex_tools::default_namespace_description; +use codex_tools::dynamic_tool_to_responses_api_tool; use serde_json::Value; use std::time::Instant; use tokio::sync::oneshot; @@ -20,23 +30,49 @@ use tracing::warn; pub struct DynamicToolHandler { tool_name: ToolName, + spec: Option, + exposure: ToolExposure, + search_text: String, } impl DynamicToolHandler { - pub fn new(tool_name: ToolName) -> Self { - Self { tool_name } + pub fn new(tool: &DynamicToolSpec) -> Option { + let tool_name = ToolName::new(tool.namespace.clone(), tool.name.clone()); + let output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?; + let spec = match tool.namespace.as_ref() { + Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace { + name: namespace.clone(), + description: default_namespace_description(namespace), + tools: vec![ResponsesApiNamespaceTool::Function(output_tool)], + }), + None => ToolSpec::Function(output_tool), + }; + Some(Self { + tool_name, + spec: Some(spec), + exposure: if tool.defer_loading { + ToolExposure::Deferred + } else { + ToolExposure::Direct + }, + search_text: build_dynamic_search_text(tool), + }) } } -impl ToolHandler for DynamicToolHandler { +impl ToolExecutor for DynamicToolHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { self.tool_name.clone() } - async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { - true + fn spec(&self) -> Option { + self.spec.clone() + } + + fn exposure(&self) -> ToolExposure { + self.exposure } async fn handle(&self, invocation: ToolInvocation) -> Result { @@ -84,6 +120,19 @@ impl ToolHandler for DynamicToolHandler { } } +impl ToolHandler for DynamicToolHandler { + fn search_info(&self) -> Option { + ToolSearchInfo::from_spec( + self.search_text.clone(), + self.spec()?, + Some(ToolSearchSourceInfo { + name: "Dynamic tools".to_string(), + description: Some("Tools provided by the current Codex thread.".to_string()), + }), + ) + } +} + #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and dynamic tool response registration must remain atomic" @@ -157,3 +206,27 @@ async fn request_dynamic_tool( response } + +fn build_dynamic_search_text(tool: &DynamicToolSpec) -> String { + let mut schema_properties = tool + .input_schema + .get("properties") + .and_then(serde_json::Value::as_object) + .map(|map| map.keys().cloned().collect::>()) + .unwrap_or_default(); + schema_properties.sort(); + let mut parts = vec![ + tool.name.clone(), + tool.name.replace('_', " "), + tool.description.clone(), + ]; + if let Some(namespace) = &tool.namespace { + parts.push(namespace.clone()); + } + parts.extend(schema_properties); + parts.join(" ") +} + +#[cfg(test)] +#[path = "dynamic_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/dynamic_tests.rs b/codex-rs/core/src/tools/handlers/dynamic_tests.rs new file mode 100644 index 0000000000..201ef45855 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/dynamic_tests.rs @@ -0,0 +1,36 @@ +use super::*; +use codex_tools::ToolSearchSourceInfo; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn search_info_uses_dynamic_tool_metadata_and_parameter_names() { + let handler = DynamicToolHandler::new(&DynamicToolSpec { + namespace: Some("codex_app".to_string()), + name: "automation_update".to_string(), + description: "Create or update automations.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "timezone": { "type": "string" }, + "mode": { "type": "string" } + } + }), + defer_loading: true, + }) + .expect("dynamic handler should be created"); + + let search_info = handler.search_info().expect("dynamic search info"); + + assert_eq!( + search_info.entry.search_text, + "automation_update automation update Create or update automations. codex_app mode timezone" + ); + assert_eq!( + search_info.source_info, + Some(ToolSearchSourceInfo { + name: "Dynamic tools".to_string(), + description: Some("Tools provided by the current Codex thread.".to_string()), + }) + ); +} diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index f76708478d..cd94d88698 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -1,9 +1,8 @@ -use codex_protocol::models::FunctionCallOutputBody; -use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseInputItem; -use codex_tool_api::ToolBundle as ExtensionToolBundle; -use codex_tool_api::ToolError as ExtensionToolError; -use codex_tools::ResponsesApiTool; +use std::sync::Arc; + +use codex_extension_api::ExtensionToolExecutor; +use codex_extension_api::ExtensionToolOutput; +use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolName; use codex_tools::ToolSpec; use serde_json::Value; @@ -16,48 +15,16 @@ use crate::tools::flat_tool_name; use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; -pub(crate) struct BundledToolOutput { - value: Value, +pub(crate) struct ExtensionToolHandler { + executor: Arc, } -impl ToolOutput for BundledToolOutput { - fn log_preview(&self) -> String { - self.value.to_string() - } - - fn success_for_logging(&self) -> bool { - true - } - - fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { - ResponseInputItem::FunctionCallOutput { - call_id: call_id.to_string(), - output: FunctionCallOutputPayload { - body: FunctionCallOutputBody::Text(self.value.to_string()), - success: Some(true), - }, - } - } - - fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { - Some(self.value.clone()) - } - - fn code_mode_result(&self, _payload: &ToolPayload) -> Value { - self.value.clone() - } -} - -pub(crate) struct BundledToolHandler { - bundle: ExtensionToolBundle, - spec: ToolSpec, -} - -impl BundledToolHandler { - pub(crate) fn new(bundle: ExtensionToolBundle, spec: ToolSpec) -> Self { - Self { bundle, spec } +impl ExtensionToolHandler { + pub(crate) fn new(executor: Arc) -> Self { + Self { executor } } fn arguments_from_payload<'a>(&self, payload: &'a ToolPayload) -> Option<&'a str> { @@ -68,25 +35,27 @@ impl BundledToolHandler { } } -impl ToolHandler for BundledToolHandler { - type Output = BundledToolOutput; +impl ToolExecutor for ExtensionToolHandler { + type Output = ExtensionToolOutput; fn tool_name(&self) -> ToolName { - ToolName::plain(self.bundle.tool_name()) + self.executor.tool_name() } fn spec(&self) -> Option { - Some(self.spec.clone()) + self.executor.spec() } + async fn handle(&self, invocation: ToolInvocation) -> Result { + self.executor.handle(to_extension_call(&invocation)).await + } +} + +impl ToolHandler for ExtensionToolHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { self.arguments_from_payload(payload).is_some() } - async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { - true - } - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { let arguments = self.arguments_from_payload(&invocation.payload)?; Some(PreToolUsePayload { @@ -109,47 +78,13 @@ impl ToolHandler for BundledToolHandler { .post_tool_use_response(&invocation.call_id, &invocation.payload)?, }) } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let arguments = self - .arguments_from_payload(&invocation.payload) - .ok_or_else(|| { - FunctionCallError::Fatal(format!( - "tool {} invoked with incompatible payload", - self.bundle.tool_name() - )) - })? - .to_string(); - let value = self - .bundle - .executor() - .execute(codex_tool_api::ToolCall { - call_id: invocation.call_id, - arguments, - }) - .await - .map_err(map_extension_tool_error)?; - Ok(BundledToolOutput { value }) - } } -pub(crate) fn extension_tool_spec( - spec: &codex_tool_api::FunctionToolSpec, -) -> Result { - Ok(ToolSpec::Function(ResponsesApiTool { - name: spec.name.clone(), - description: spec.description.clone(), - strict: spec.strict, - defer_loading: None, - parameters: codex_tools::parse_tool_input_schema(&spec.parameters)?, - output_schema: None, - })) -} - -fn map_extension_tool_error(error: ExtensionToolError) -> FunctionCallError { - match error { - ExtensionToolError::RespondToModel(message) => FunctionCallError::RespondToModel(message), - ExtensionToolError::Fatal(message) => FunctionCallError::Fatal(message), +fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { + ExtensionToolCall { + call_id: invocation.call_id.clone(), + tool_name: invocation.tool_name.clone(), + payload: invocation.payload.clone(), } } @@ -168,9 +103,7 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::json; - use super::BundledToolHandler; - use super::BundledToolOutput; - use super::extension_tool_spec; + use super::ExtensionToolHandler; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -182,32 +115,43 @@ mod tests { struct StubExtensionExecutor; - impl codex_tool_api::ToolExecutor for StubExtensionExecutor { - fn execute(&self, _call: codex_tool_api::ToolCall) -> codex_tool_api::ToolFuture<'_> { - Box::pin(async { Ok(json!({ "ok": true })) }) + impl codex_extension_api::ExtensionToolExecutor for StubExtensionExecutor { + fn tool_name(&self) -> codex_tools::ToolName { + codex_tools::ToolName::plain("extension_echo") + } + + fn spec(&self) -> Option { + Some(codex_tools::ToolSpec::Function( + codex_tools::ResponsesApiTool { + name: "extension_echo".to_string(), + description: "Echoes arguments.".to_string(), + strict: true, + parameters: codex_tools::parse_tool_input_schema(&json!({ + "type": "object", + "properties": { + "message": { "type": "string" }, + }, + "required": ["message"], + "additionalProperties": false, + })) + .expect("extension schema should parse"), + output_schema: None, + defer_loading: None, + }, + )) + } + + fn handle( + &self, + _call: codex_tools::ToolCall, + ) -> codex_extension_api::ExtensionToolFuture<'_> { + Box::pin(async { Ok(codex_tools::JsonToolOutput::new(json!({ "ok": true }))) }) } } #[tokio::test] - async fn exposes_generic_hook_payloads_and_is_conservatively_mutating() { - let bundle = codex_tool_api::ToolBundle::new( - codex_tool_api::FunctionToolSpec { - name: "extension_echo".to_string(), - description: "Echoes arguments.".to_string(), - strict: true, - parameters: json!({ - "type": "object", - "properties": { - "message": { "type": "string" }, - }, - "required": ["message"], - "additionalProperties": false, - }), - }, - Arc::new(StubExtensionExecutor), - ); - let spec = extension_tool_spec(bundle.spec()).expect("extension spec should convert"); - let handler = BundledToolHandler::new(bundle, spec); + async fn exposes_generic_hook_payloads() { + let handler = ExtensionToolHandler::new(Arc::new(StubExtensionExecutor)); let (session, turn) = crate::session::tests::make_session_and_context().await; let invocation = ToolInvocation { session: session.into(), @@ -221,11 +165,8 @@ mod tests { arguments: json!({ "message": "hello" }).to_string(), }, }; - let output = BundledToolOutput { - value: json!({ "ok": true }), - }; + let output = codex_tools::JsonToolOutput::new(json!({ "ok": true })); - assert!(ToolHandler::is_mutating(&handler, &invocation).await); assert_eq!( ToolHandler::pre_tool_use_payload(&handler, &invocation), Some(PreToolUsePayload { diff --git a/codex-rs/core/src/tools/handlers/goal/create_goal.rs b/codex-rs/core/src/tools/handlers/goal/create_goal.rs index db33445a2a..34a4c9307c 100644 --- a/codex-rs/core/src/tools/handlers/goal/create_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/create_goal.rs @@ -6,6 +6,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::goal_spec::CREATE_GOAL_TOOL_NAME; use crate::tools::handlers::goal_spec::create_create_goal_tool; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -17,7 +18,7 @@ use super::goal_response; pub struct CreateGoalHandler; -impl ToolHandler for CreateGoalHandler { +impl ToolExecutor for CreateGoalHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -71,3 +72,5 @@ impl ToolHandler for CreateGoalHandler { goal_response(Some(goal), CompletionBudgetReport::Omit) } } + +impl ToolHandler for CreateGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal/get_goal.rs b/codex-rs/core/src/tools/handlers/goal/get_goal.rs index d3c587b3ae..3c0813329e 100644 --- a/codex-rs/core/src/tools/handlers/goal/get_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/get_goal.rs @@ -4,6 +4,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::goal_spec::GET_GOAL_TOOL_NAME; use crate::tools::handlers::goal_spec::create_get_goal_tool; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -14,7 +15,7 @@ use super::goal_response; pub struct GetGoalHandler; -impl ToolHandler for GetGoalHandler { +impl ToolExecutor for GetGoalHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -44,3 +45,5 @@ impl ToolHandler for GetGoalHandler { } } } + +impl ToolHandler for GetGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal/update_goal.rs b/codex-rs/core/src/tools/handlers/goal/update_goal.rs index 8e80a90138..18b59c1b36 100644 --- a/codex-rs/core/src/tools/handlers/goal/update_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/update_goal.rs @@ -7,6 +7,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; use crate::tools::handlers::goal_spec::create_update_goal_tool; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_protocol::protocol::ThreadGoalStatus; use codex_tools::ToolName; @@ -19,7 +20,7 @@ use super::goal_response; pub struct UpdateGoalHandler; -impl ToolHandler for UpdateGoalHandler { +impl ToolExecutor for UpdateGoalHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -74,3 +75,5 @@ impl ToolHandler for UpdateGoalHandler { goal_response(Some(goal), CompletionBudgetReport::Include) } } + +impl ToolHandler for UpdateGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 1fc50d43d2..6f70c93d73 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -8,37 +8,153 @@ use crate::tools::context::McpToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::flat_tool_name; use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; +use crate::tools::registry::ToolExecutor; +use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolTelemetryTags; +use crate::tools::tool_search_entry::ToolSearchInfo; use codex_mcp::ToolInfo; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolName; +use codex_tools::ToolSearchSourceInfo; +use codex_tools::ToolSpec; +use codex_tools::mcp_tool_to_responses_api_tool; use serde_json::Map; use serde_json::Value; pub struct McpHandler { tool_info: ToolInfo, + exposure: ToolExposure, } impl McpHandler { pub fn new(tool_info: ToolInfo) -> Self { - Self { tool_info } + Self::with_exposure(tool_info, ToolExposure::Direct) + } + + pub fn with_exposure(tool_info: ToolInfo, exposure: ToolExposure) -> Self { + Self { + tool_info, + exposure, + } } } -impl ToolHandler for McpHandler { +impl ToolExecutor for McpHandler { type Output = McpToolOutput; fn tool_name(&self) -> ToolName { self.tool_info.canonical_tool_name() } + fn spec(&self) -> Option { + let tool_name = self.tool_name(); + let namespace_name = tool_name.namespace.as_ref()?; + let tool = mcp_tool_to_responses_api_tool(&tool_name, &self.tool_info.tool).ok()?; + let description = self + .tool_info + .namespace_description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) + .or_else(|| { + self.tool_info + .connector_name + .as_deref() + .map(str::trim) + .filter(|connector_name| !connector_name.is_empty()) + .map(|connector_name| format!("Tools for working with {connector_name}.")) + }) + .unwrap_or_default(); + + Some(ToolSpec::Namespace(ResponsesApiNamespace { + name: namespace_name.clone(), + description, + tools: vec![ResponsesApiNamespaceTool::Function(tool)], + })) + } + + fn exposure(&self) -> ToolExposure { + self.exposure + } + fn supports_parallel_tool_calls(&self) -> bool { self.tool_info.supports_parallel_tool_calls } + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let payload = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "mcp handler received unsupported payload".to_string(), + )); + } + }; + + let started = Instant::now(); + let result = handle_mcp_tool_call( + Arc::clone(&session), + &turn, + call_id.clone(), + self.tool_info.server_name.clone(), + self.tool_info.tool.name.to_string(), + self.tool_name().to_string(), + payload, + ) + .await; + + Ok(McpToolOutput { + result: result.result, + tool_input: result.tool_input, + wall_time: started.elapsed(), + original_image_detail_supported: can_request_original_image_detail(&turn.model_info), + truncation_policy: turn.truncation_policy, + }) + } +} + +impl ToolHandler for McpHandler { + fn search_info(&self) -> Option { + let source_name = self + .tool_info + .connector_name + .as_deref() + .map(str::trim) + .filter(|connector_name| !connector_name.is_empty()) + .unwrap_or_else(|| self.tool_info.server_name.trim()); + let source_info = (!source_name.is_empty()).then(|| ToolSearchSourceInfo { + name: source_name.to_string(), + description: self + .tool_info + .namespace_description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string), + }); + + ToolSearchInfo::from_spec( + build_mcp_search_text(&self.tool_info), + self.spec()?, + source_info, + ) + } + async fn telemetry_tags(&self, _invocation: &ToolInvocation) -> ToolTelemetryTags { let mut tags = vec![("mcp_server", self.tool_info.server_name.clone())]; if let Some(origin) = &self.tool_info.server_origin { @@ -98,45 +214,6 @@ impl ToolHandler for McpHandler { tool_response, }) } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - call_id, - payload, - .. - } = invocation; - - let payload = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "mcp handler received unsupported payload".to_string(), - )); - } - }; - - let started = Instant::now(); - let result = handle_mcp_tool_call( - Arc::clone(&session), - &turn, - call_id.clone(), - self.tool_info.server_name.clone(), - self.tool_info.tool.name.to_string(), - self.tool_name().to_string(), - payload, - ) - .await; - - Ok(McpToolOutput { - result: result.result, - tool_input: result.tool_input, - wall_time: started.elapsed(), - original_image_detail_supported: can_request_original_image_detail(&turn.model_info), - truncation_policy: turn.truncation_policy, - }) - } } fn mcp_hook_tool_input(raw_arguments: &str) -> Value { @@ -147,6 +224,58 @@ fn mcp_hook_tool_input(raw_arguments: &str) -> Value { serde_json::from_str(raw_arguments).unwrap_or_else(|_| Value::String(raw_arguments.to_string())) } +fn build_mcp_search_text(info: &ToolInfo) -> String { + let tool_name = info.canonical_tool_name(); + let mut schema_properties = info + .tool + .input_schema + .get("properties") + .and_then(serde_json::Value::as_object) + .map(|map| map.keys().cloned().collect::>()) + .unwrap_or_default(); + schema_properties.sort(); + let mut parts = vec![ + flat_tool_name(&tool_name).into_owned(), + info.callable_name.clone(), + info.tool.name.to_string(), + info.server_name.clone(), + ]; + if let Some(title) = info.tool.title.as_deref().map(str::trim) + && !title.is_empty() + { + parts.push(title.to_string()); + } + if let Some(description) = info.tool.description.as_deref().map(str::trim) + && !description.is_empty() + { + parts.push(description.to_string()); + } + if let Some(connector_name) = info.connector_name.as_deref().map(str::trim) + && !connector_name.is_empty() + { + parts.push(connector_name.to_string()); + } + if let Some(namespace_description) = info.namespace_description.as_deref().map(str::trim) + && !namespace_description.is_empty() + { + parts.push(namespace_description.to_string()); + } + parts.extend( + info.plugin_display_names + .iter() + .map(String::as_str) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string), + ); + parts.extend(schema_properties); + parts.join(" ") +} + +#[cfg(test)] +#[path = "mcp_search_tests.rs"] +mod search_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs index 14a5a9d2b9..0aea2b335e 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs @@ -5,6 +5,7 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::mcp_resource_spec::create_list_mcp_resource_templates_tool; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::protocol::McpInvocation; @@ -25,7 +26,7 @@ use super::serialize_function_output; pub struct ListMcpResourceTemplatesHandler; -impl ToolHandler for ListMcpResourceTemplatesHandler { +impl ToolExecutor for ListMcpResourceTemplatesHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -163,3 +164,5 @@ impl ToolHandler for ListMcpResourceTemplatesHandler { } } } + +impl ToolHandler for ListMcpResourceTemplatesHandler {} diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs index eaa5a54e08..b412f811bb 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs @@ -5,6 +5,7 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::mcp_resource_spec::create_list_mcp_resources_tool; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::protocol::McpInvocation; @@ -25,7 +26,7 @@ use super::serialize_function_output; pub struct ListMcpResourcesHandler; -impl ToolHandler for ListMcpResourcesHandler { +impl ToolExecutor for ListMcpResourcesHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -161,3 +162,5 @@ impl ToolHandler for ListMcpResourcesHandler { } } } + +impl ToolHandler for ListMcpResourcesHandler {} diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs index 51bb6fa67f..89d061d692 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs @@ -5,6 +5,7 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::mcp_resource_spec::create_read_mcp_resource_tool; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::protocol::McpInvocation; @@ -25,7 +26,7 @@ use super::serialize_function_output; pub struct ReadMcpResourceHandler; -impl ToolHandler for ReadMcpResourceHandler { +impl ToolExecutor for ReadMcpResourceHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -144,3 +145,5 @@ impl ToolHandler for ReadMcpResourceHandler { } } } + +impl ToolHandler for ReadMcpResourceHandler {} diff --git a/codex-rs/core/src/tools/handlers/mcp_search_tests.rs b/codex-rs/core/src/tools/handlers/mcp_search_tests.rs new file mode 100644 index 0000000000..b9467bad4e --- /dev/null +++ b/codex-rs/core/src/tools/handlers/mcp_search_tests.rs @@ -0,0 +1,75 @@ +use super::*; +use codex_tools::LoadableToolSpec; +use codex_tools::ToolSearchSourceInfo; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn search_info_uses_mcp_tool_metadata_and_parameter_names() { + let handler = McpHandler::new(tool_info()); + let search_info = handler.search_info().expect("MCP search info"); + + assert_eq!( + search_info.entry.search_text, + "mcp__calendar___create_event _create_event createEvent codex-apps Create event Create a calendar event. Calendar Plan events. Calendar plugin attendees start_time" + ); + assert_eq!( + search_info.source_info, + Some(ToolSearchSourceInfo { + name: "Calendar".to_string(), + description: Some("Plan events.".to_string()), + }) + ); +} + +#[test] +fn search_info_uses_connector_name_for_output_namespace_description() { + let mut tool_info = tool_info(); + tool_info.namespace_description = None; + let handler = McpHandler::new(tool_info); + let search_info = handler.search_info().expect("MCP search info"); + + let LoadableToolSpec::Namespace(namespace) = search_info.entry.output else { + panic!("expected namespace search output"); + }; + assert_eq!(namespace.description, "Tools for working with Calendar."); + assert_eq!( + search_info.source_info, + Some(ToolSearchSourceInfo { + name: "Calendar".to_string(), + description: None, + }) + ); +} + +fn tool_info() -> ToolInfo { + ToolInfo { + server_name: "codex-apps".to_string(), + supports_parallel_tool_calls: false, + server_origin: None, + callable_name: "_create_event".to_string(), + callable_namespace: "mcp__calendar__".to_string(), + namespace_description: Some("Plan events.".to_string()), + tool: rmcp::model::Tool { + name: "createEvent".to_string().into(), + title: Some("Create event".to_string()), + description: Some("Create a calendar event.".to_string().into()), + input_schema: Arc::new(rmcp::model::object(json!({ + "type": "object", + "properties": { + "start_time": { "type": "string" }, + "attendees": { "type": "string" } + }, + "additionalProperties": false + }))), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: None, + connector_name: Some("Calendar".to_string()), + plugin_display_names: vec![" Calendar plugin ".to_string(), " ".to_string()], + } +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index d689b0cf35..69281acc7d 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -26,7 +26,6 @@ mod test_sync; pub(crate) mod test_sync_spec; mod tool_search; pub(crate) mod tool_search_spec; -mod unavailable_tool; pub(crate) mod unified_exec; mod view_image; pub(crate) mod view_image_spec; @@ -63,15 +62,10 @@ pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_plugin_install::RequestPluginInstallHandler; pub use request_user_input::RequestUserInputHandler; -pub use shell::ContainerExecHandler; -pub use shell::LocalShellHandler; pub use shell::ShellCommandHandler; pub(crate) use shell::ShellCommandHandlerOptions; -pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; pub use tool_search::ToolSearchHandler; -pub use unavailable_tool::UnavailableToolHandler; -pub(crate) use unavailable_tool::unavailable_tool_message; pub use unified_exec::ExecCommandHandler; pub(crate) use unified_exec::ExecCommandHandlerOptions; pub use unified_exec::WriteStdinHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 1262633969..d0c5edfb77 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -15,6 +15,7 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; pub(crate) use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_protocol::ThreadId; use codex_protocol::models::ResponseInputItem; 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 e4788dd9b8..beac3a96f4 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 @@ -5,7 +5,7 @@ use codex_tools::ToolSpec; pub(crate) struct Handler; -impl ToolHandler for Handler { +impl ToolExecutor for Handler { type Output = CloseAgentResult; fn tool_name(&self) -> ToolName { @@ -16,90 +16,101 @@ impl ToolHandler for Handler { Some(create_close_agent_tool_v1()) } + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(handle_close_agent(invocation)) + } +} + +async fn handle_close_agent( + invocation: ToolInvocation, +) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: CloseAgentArgs = parse_arguments(&arguments)?; + let agent_id = parse_agent_id_target(&args.target)?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(agent_id) + .unwrap_or_default(); + session + .send_event( + &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, + } + .into(), + ) + .await; + let status = match session + .services + .agent_control + .subscribe_status(agent_id) + .await + { + Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(err) => { + let status = session.services.agent_control.get_status(agent_id).await; + session + .send_event( + &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(), + receiver_agent_role: receiver_agent.agent_role.clone(), + status, + } + .into(), + ) + .await; + return Err(collab_agent_error(agent_id, err)); + } + }; + let result = Box::pin(session.services.agent_control.close_agent(agent_id)) + .await + .map_err(|err| collab_agent_error(agent_id, err)) + .map(|_| ()); + session + .send_event( + &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, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + result?; + + Ok(CloseAgentResult { + previous_status: status, + }) +} + +impl ToolHandler for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: CloseAgentArgs = parse_arguments(&arguments)?; - let agent_id = parse_agent_id_target(&args.target)?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(agent_id) - .unwrap_or_default(); - session - .send_event( - &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, - } - .into(), - ) - .await; - let status = match session - .services - .agent_control - .subscribe_status(agent_id) - .await - { - Ok(mut status_rx) => status_rx.borrow_and_update().clone(), - Err(err) => { - let status = session.services.agent_control.get_status(agent_id).await; - session - .send_event( - &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(), - receiver_agent_role: receiver_agent.agent_role.clone(), - status, - } - .into(), - ) - .await; - return Err(collab_agent_error(agent_id, err)); - } - }; - let result = Box::pin(session.services.agent_control.close_agent(agent_id)) - .await - .map_err(|err| collab_agent_error(agent_id, err)) - .map(|_| ()); - session - .send_event( - &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, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), - ) - .await; - result?; - - Ok(CloseAgentResult { - previous_status: status, - }) - } } #[derive(Debug, Deserialize, Serialize)] 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 fa77bc37bb..1e9f5d9b07 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 @@ -7,7 +7,7 @@ use std::sync::Arc; pub(crate) struct Handler; -impl ToolHandler for Handler { +impl ToolExecutor for Handler { type Output = ResumeAgentResult; fn tool_name(&self) -> ToolName { @@ -18,115 +18,126 @@ impl ToolHandler for Handler { Some(create_resume_agent_tool()) } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(handle_resume_agent(invocation)) + } +} + +async fn handle_resume_agent( + invocation: ToolInvocation, +) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: ResumeAgentArgs = parse_arguments(&arguments)?; + let receiver_thread_id = ThreadId::from_string(&args.id).map_err(|err| { + FunctionCallError::RespondToModel(format!("invalid agent id {}: {err:?}", args.id)) + })?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or_default(); + let child_depth = next_thread_spawn_depth(&turn.session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: ResumeAgentArgs = parse_arguments(&arguments)?; - let receiver_thread_id = ThreadId::from_string(&args.id).map_err(|err| { - FunctionCallError::RespondToModel(format!("invalid agent id {}: {err:?}", args.id)) - })?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(receiver_thread_id) - .unwrap_or_default(); - let child_depth = next_thread_spawn_depth(&turn.session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { - return Err(FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string(), - )); - } - - session - .send_event( - &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(), - receiver_agent_role: receiver_agent.agent_role.clone(), - } - .into(), - ) - .await; - - let mut status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - let (receiver_agent, error) = if matches!(status, AgentStatus::NotFound) { - match Box::pin(try_resume_closed_agent( - &session, - &turn, + session + .send_event( + &turn, + CollabResumeBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, receiver_thread_id, - child_depth, - )) - .await - { - Ok(()) => { - status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - ( - session - .services - .agent_control - .get_agent_metadata(receiver_thread_id) - .unwrap_or(receiver_agent), - None, - ) - } - Err(err) => { - status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - (receiver_agent, Some(err)) - } + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), } - } else { - (receiver_agent, None) - }; - session - .send_event( - &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, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), - ) - .await; + .into(), + ) + .await; - if let Some(err) = error { - return Err(err); + let mut status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + let (receiver_agent, error) = if matches!(status, AgentStatus::NotFound) { + match Box::pin(try_resume_closed_agent( + &session, + &turn, + receiver_thread_id, + child_depth, + )) + .await + { + Ok(()) => { + status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + ( + session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or(receiver_agent), + None, + ) + } + Err(err) => { + status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + (receiver_agent, Some(err)) + } } - turn.session_telemetry - .counter("codex.multi_agent.resume", /*inc*/ 1, &[]); + } else { + (receiver_agent, None) + }; + session + .send_event( + &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, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; - Ok(ResumeAgentResult { status }) + if let Some(err) = error { + return Err(err); + } + turn.session_telemetry + .counter("codex.multi_agent.resume", /*inc*/ 1, &[]); + + Ok(ResumeAgentResult { status }) +} + +impl ToolHandler for Handler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) } } 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 cdd46a90d0..ac212f7556 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 @@ -6,7 +6,7 @@ use codex_tools::ToolSpec; pub(crate) struct Handler; -impl ToolHandler for Handler { +impl ToolExecutor for Handler { type Output = SendInputResult; fn tool_name(&self) -> ToolName { @@ -17,10 +17,6 @@ impl ToolHandler for Handler { Some(create_send_input_tool_v1()) } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, @@ -92,6 +88,12 @@ impl ToolHandler for Handler { } } +impl ToolHandler for Handler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } +} + #[derive(Debug, Deserialize)] struct SendInputArgs { target: String, 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 0317658955..395e74f137 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -22,7 +22,7 @@ impl Handler { } } -impl ToolHandler for Handler { +impl ToolExecutor for Handler { type Output = SpawnAgentResult; fn tool_name(&self) -> ToolName { @@ -33,163 +33,177 @@ impl ToolHandler for Handler { Some(create_spawn_agent_tool_v1(self.options.clone())) } + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(handle_spawn_agent(invocation)) + } +} + +async fn handle_spawn_agent( + invocation: ToolInvocation, +) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); + let input_items = parse_collab_input(args.message, args.items)?; + let prompt = render_input_preview(&input_items); + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); + } + session + .send_event( + &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(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), + } + .into(), + ) + .await; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + if args.fork_context { + reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?; + } else { + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + apply_role_to_config(&mut config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + } + apply_spawn_agent_service_tier( + &session, + &mut config, + turn.config.service_tier.as_deref(), + args.service_tier.as_deref(), + ) + .await?; + 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.to_selections()), + }, + )) + .await + .map_err(collab_spawn_error); + let (new_thread_id, new_agent_metadata, status) = match &result { + Ok(spawned_agent) => ( + Some(spawned_agent.thread_id), + Some(spawned_agent.metadata.clone()), + spawned_agent.status.clone(), + ), + Err(_) => (None, None, AgentStatus::NotFound), + }; + let agent_snapshot = match new_thread_id { + Some(thread_id) => { + session + .services + .agent_control + .get_agent_config_snapshot(thread_id) + .await + } + None => None, + }; + let (_new_agent_path, new_agent_nickname, new_agent_role) = + match (&agent_snapshot, new_agent_metadata) { + (Some(snapshot), _) => ( + snapshot.session_source.get_agent_path().map(String::from), + snapshot.session_source.get_nickname(), + snapshot.session_source.get_agent_role(), + ), + (None, Some(metadata)) => ( + metadata.agent_path.map(String::from), + metadata.agent_nickname, + metadata.agent_role, + ), + (None, None) => (None, None, None), + }; + let effective_model = agent_snapshot + .as_ref() + .map(|snapshot| snapshot.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()); + let effective_reasoning_effort = agent_snapshot + .as_ref() + .and_then(|snapshot| snapshot.reasoning_effort) + .unwrap_or(args.reasoning_effort.unwrap_or_default()); + let nickname = new_agent_nickname.clone(); + session + .send_event( + &turn, + CollabAgentSpawnEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + new_thread_id, + new_agent_nickname, + new_agent_role, + prompt, + model: effective_model, + reasoning_effort: effective_reasoning_effort, + status, + } + .into(), + ) + .await; + let new_thread_id = result?.thread_id; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.session_telemetry.counter( + "codex.multi_agent.spawn", + /*inc*/ 1, + &[("role", role_tag)], + ); + + Ok(SpawnAgentResult { + agent_id: new_thread_id.to_string(), + nickname, + }) +} + +impl ToolHandler for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let role_name = args - .agent_type - .as_deref() - .map(str::trim) - .filter(|role| !role.is_empty()); - let input_items = parse_collab_input(args.message, args.items)?; - let prompt = render_input_preview(&input_items); - let session_source = turn.session_source.clone(); - let child_depth = next_thread_spawn_depth(&session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { - return Err(FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string(), - )); - } - session - .send_event( - &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(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), - } - .into(), - ) - .await; - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if args.fork_context { - reject_full_fork_spawn_overrides( - role_name, - args.model.as_deref(), - args.reasoning_effort, - )?; - } else { - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; - apply_role_to_config(&mut config, role_name) - .await - .map_err(FunctionCallError::RespondToModel)?; - } - 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.to_selections()), - }, - )) - .await - .map_err(collab_spawn_error); - let (new_thread_id, new_agent_metadata, status) = match &result { - Ok(spawned_agent) => ( - Some(spawned_agent.thread_id), - Some(spawned_agent.metadata.clone()), - spawned_agent.status.clone(), - ), - Err(_) => (None, None, AgentStatus::NotFound), - }; - let agent_snapshot = match new_thread_id { - Some(thread_id) => { - session - .services - .agent_control - .get_agent_config_snapshot(thread_id) - .await - } - None => None, - }; - let (_new_agent_path, new_agent_nickname, new_agent_role) = - match (&agent_snapshot, new_agent_metadata) { - (Some(snapshot), _) => ( - snapshot.session_source.get_agent_path().map(String::from), - snapshot.session_source.get_nickname(), - snapshot.session_source.get_agent_role(), - ), - (None, Some(metadata)) => ( - metadata.agent_path.map(String::from), - metadata.agent_nickname, - metadata.agent_role, - ), - (None, None) => (None, None, None), - }; - let effective_model = agent_snapshot - .as_ref() - .map(|snapshot| snapshot.model.clone()) - .unwrap_or_else(|| args.model.clone().unwrap_or_default()); - let effective_reasoning_effort = agent_snapshot - .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) - .unwrap_or(args.reasoning_effort.unwrap_or_default()); - let nickname = new_agent_nickname.clone(); - session - .send_event( - &turn, - CollabAgentSpawnEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - model: effective_model, - reasoning_effort: effective_reasoning_effort, - status, - } - .into(), - ) - .await; - let new_thread_id = result?.thread_id; - let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); - turn.session_telemetry.counter( - "codex.multi_agent.spawn", - /*inc*/ 1, - &[("role", role_tag)], - ); - - Ok(SpawnAgentResult { - agent_id: new_thread_id.to_string(), - nickname, - }) - } } #[derive(Debug, Deserialize)] @@ -199,6 +213,7 @@ struct SpawnAgentArgs { agent_type: Option, model: Option, reasoning_effort: Option, + service_tier: Option, #[serde(default)] fork_context: bool, } 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 17739606f9..aa9d6f6581 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -27,7 +27,7 @@ impl Handler { } } -impl ToolHandler for Handler { +impl ToolExecutor for Handler { type Output = WaitAgentResult; fn tool_name(&self) -> ToolName { @@ -38,10 +38,6 @@ impl ToolHandler for Handler { Some(create_wait_agent_tool_v1(self.options)) } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, @@ -204,6 +200,12 @@ impl ToolHandler for Handler { } } +impl ToolHandler for Handler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } +} + #[derive(Debug, Deserialize)] struct WaitArgs { #[serde(default)] diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index c01755cb2b..cc4e9ebfca 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -336,6 +336,51 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides( Ok(()) } +pub(crate) async fn apply_spawn_agent_service_tier( + session: &Session, + config: &mut Config, + parent_service_tier: Option<&str>, + requested_service_tier: Option<&str>, +) -> Result<(), FunctionCallError> { + let Some(candidate_service_tier) = requested_service_tier.or(parent_service_tier) else { + return Ok(()); + }; + let model = config.model.clone().ok_or_else(|| { + FunctionCallError::RespondToModel( + "spawn_agent could not resolve the child model for service tier validation".to_string(), + ) + })?; + let model_info = session + .services + .models_manager + .get_model_info(model.as_str(), &config.to_models_manager_config()) + .await; + + if model_info.supports_service_tier(candidate_service_tier) { + config.service_tier = Some(candidate_service_tier.to_string()); + return Ok(()); + } + + if requested_service_tier.is_none() { + config.service_tier = None; + return Ok(()); + } + + let supported_service_tiers = if model_info.service_tiers.is_empty() { + "none".to_string() + } else { + model_info + .service_tiers + .iter() + .map(|tier| tier.id.as_str()) + .collect::>() + .join(", ") + }; + Err(FunctionCallError::RespondToModel(format!( + "Service tier `{candidate_service_tier}` is not supported for model `{model}`. Supported service tiers: {supported_service_tiers}" + ))) +} + fn find_spawn_agent_model_name( available_models: &[codex_protocol::openai_models::ModelPreset], requested_model: &str, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 233491efa4..111ab67798 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -8,6 +8,7 @@ use std::collections::BTreeMap; const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed."; const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one."; +const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Optional service tier override for the new agent. Leave unset unless the user explicitly asks for one."; #[derive(Debug, Clone, Default)] pub struct SpawnAgentToolOptions { @@ -545,6 +546,12 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap BTreeMap String { .map(|preset| format!("{} ({})", preset.effort, preset.description)) .collect::>() .join(", "); + let service_tiers = if model.service_tiers.is_empty() { + "none".to_string() + } else { + model + .service_tiers + .iter() + .map(|tier| format!("{} ({}: {})", tier.id, tier.name, tier.description)) + .collect::>() + .join(", ") + }; format!( - "- {} (`{}`): {} Default reasoning effort: {}. Supported reasoning efforts: {}.", + "- {} (`{}`): {} Default reasoning effort: {}. Supported reasoning efforts: {}. Supported service tiers: {}.", model.display_name, model.model, model.description, model.default_reasoning_effort, - efforts + efforts, + service_tiers ) }) .collect::>() diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index c8fa1a0f90..aaa29d6d4c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -1,5 +1,6 @@ use super::*; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_tools::JsonSchemaPrimitiveType; @@ -20,7 +21,11 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset { }], supports_personality: false, additional_speed_tiers: Vec::new(), - service_tiers: Vec::new(), + service_tiers: vec![ModelServiceTier { + id: "priority".to_string(), + name: "Fast".to_string(), + description: "1.5x speed, increased usage".to_string(), + }], is_default: false, upgrade: None, show_in_picker, @@ -70,6 +75,10 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { .contains("Available model overrides (optional; inherited parent model is preferred):") ); assert!(description.contains("visible display (`visible-model`)")); + assert!( + description + .contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage).") + ); assert!(!description.contains("hidden display (`hidden-model`)")); assert!(properties.contains_key("task_name")); assert!(properties.contains_key("message")); @@ -86,6 +95,12 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { .and_then(|schema| schema.description.as_deref()), Some(SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION) ); + assert_eq!( + properties + .get("service_tier") + .and_then(|schema| schema.description.as_deref()), + Some(SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION) + ); assert_eq!( parameters.required.as_ref(), Some(&vec!["task_name".to_string(), "message".to_string()]) @@ -127,6 +142,37 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { .and_then(|schema| schema.description.as_deref()), Some(SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION) ); + assert_eq!( + properties + .get("service_tier") + .and_then(|schema| schema.description.as_deref()), + Some(SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION) + ); +} + +#[test] +fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { + let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions { + available_models: vec![model_preset("visible", /*show_in_picker*/ true)], + agent_type_description: "role help".to_string(), + hide_agent_type_model_reasoning: true, + include_usage_hint: true, + usage_hint_text: None, + max_concurrent_threads_per_session: Some(4), + }); + + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { + panic!("spawn_agent should be a function tool"); + }; + let properties = parameters + .properties + .as_ref() + .expect("spawn_agent should use object params"); + + assert!(!properties.contains_key("agent_type")); + assert!(!properties.contains_key("model")); + assert!(!properties.contains_key("reasoning_effort")); + assert!(!properties.contains_key("service_tier")); } #[test] 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 b030cb010c..f051fb0f70 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -23,6 +23,7 @@ use codex_model_provider::create_model_provider; use codex_model_provider_info::built_in_model_providers; use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; @@ -444,6 +445,373 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over ); } +#[tokio::test] +async fn spawn_agent_service_tier_override_validates_the_effective_child_model() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + } + + { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let output = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "model": "gpt-5.4", + "service_tier": ServiceTier::Fast.request_value() + })), + )) + .await + .expect("spawn_agent should accept a supported explicit service tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!( + snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); + } + + { + let (session, turn) = make_session_and_context().await; + let err = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "model": "gpt-5.4", + "service_tier": "turbo" + })), + )) + .await + .expect_err("unknown service tier should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel( + "Service tier `turbo` is not supported for model `gpt-5.4`. Supported service tiers: priority" + .to_string() + ) + ); + } + + { + let (session, turn) = make_session_and_context().await; + let err = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "model": "gpt-5.3-codex", + "service_tier": ServiceTier::Fast.request_value() + })), + )) + .await + .expect_err("tier unsupported by the final child model should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel( + "Service tier `priority` is not supported for model `gpt-5.3-codex`. Supported service tiers: none" + .to_string() + ) + ); + } +} + +#[tokio::test] +async fn spawn_agent_service_tier_inheritance_preserves_supported_or_configured_tiers() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + } + + { + let (mut session, turn) = make_session_and_context().await; + let mut turn = turn + .with_model("gpt-5.4".to_string(), &session.services.models_manager) + .await; + let mut config = (*turn.config).clone(); + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + turn.config = Arc::new(config); + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let output = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({"message": "inspect this repo"})), + )) + .await + .expect("spawn_agent should inherit a supported parent service tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!( + snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); + } + + { + let (mut session, turn) = make_session_and_context().await; + let mut turn = turn + .with_model("gpt-5.4".to_string(), &session.services.models_manager) + .await; + let mut config = (*turn.config).clone(); + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + turn.config = Arc::new(config); + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let output = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "model": "gpt-5.3-codex" + })), + )) + .await + .expect("spawn_agent should clear unsupported inherited service tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!(snapshot.service_tier, None); + } + + { + let (mut session, mut turn) = make_session_and_context().await; + tokio::fs::create_dir_all(&turn.config.codex_home) + .await + .expect("codex home should be created"); + let role_config_path = turn + .config + .codex_home + .as_path() + .join("service-tier-role.toml"); + tokio::fs::write( + &role_config_path, + r#"model = "gpt-5.4" +service_tier = "priority" +"#, + ) + .await + .expect("role config should be written"); + + let role_name = "service-tier-role".to_string(); + let mut config = (*turn.config).clone(); + config.agent_roles.insert( + role_name.clone(), + AgentRoleConfig { + description: Some("Role with a child service tier".to_string()), + config_file: Some(role_config_path), + nickname_candidates: None, + }, + ); + turn.config = Arc::new(config); + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let output = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "agent_type": role_name + })), + )) + .await + .expect("spawn_agent should preserve the child role service tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!( + snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); + } +} + +#[tokio::test] +async fn spawn_agent_full_history_fork_accepts_explicit_service_tier() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + } + + let (mut session, turn) = make_session_and_context().await; + let turn = turn + .with_model("gpt-5.4".to_string(), &session.services.models_manager) + .await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let output = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "fork_context": true, + "service_tier": ServiceTier::Fast.request_value() + })), + )) + .await + .expect("full-history fork should accept explicit service tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!( + snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); +} + +#[tokio::test] +async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + task_name: String, + } + + let (mut session, turn) = make_session_and_context().await; + let mut turn = turn + .with_model("gpt-5.4".to_string(), &session.services.models_manager) + .await; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let session = Arc::new(session); + let turn = Arc::new(turn); + + let output = SpawnAgentHandlerV2::default() + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "fork_with_tier", + "service_tier": ServiceTier::Fast.request_value() + })), + )) + .await + .expect("multi-agent v2 full-history fork should accept explicit service tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let child_thread_id = session + .services + .agent_control + .resolve_agent_reference( + session.conversation_id, + &turn.session_source, + result.task_name.as_str(), + ) + .await + .expect("spawned task name should resolve"); + let snapshot = manager + .get_thread(child_thread_id) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!( + snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); +} + #[tokio::test] async fn multi_agent_v2_spawn_partial_fork_turns_allows_agent_type_override() { let (mut session, mut turn) = make_session_and_context().await; @@ -3179,10 +3547,11 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr let parent_thread_id = parent.thread_id; let parent_session = parent.thread.codex.session.clone(); + let child_turn = parent_session.new_default_turn().await; let child_spawn_output = SpawnAgentHandler::default() .handle(invocation( parent_session.clone(), - parent_session.new_default_turn().await, + child_turn, "spawn_agent", function_payload(json!({"message": "hello child"})), )) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index b40d855234..0190fe04ac 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -8,6 +8,7 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_protocol::AgentPath; use codex_protocol::models::ResponseInputItem; 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 6f2984c338..c901df9ae0 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 @@ -5,7 +5,7 @@ use codex_tools::ToolSpec; pub(crate) struct Handler; -impl ToolHandler for Handler { +impl ToolExecutor for Handler { type Output = CloseAgentResult; fn tool_name(&self) -> ToolName { @@ -16,102 +16,113 @@ impl ToolHandler for Handler { Some(create_close_agent_tool_v2()) } + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(handle_close_agent(invocation)) + } +} + +async fn handle_close_agent( + invocation: ToolInvocation, +) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: CloseAgentArgs = parse_arguments(&arguments)?; + let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(agent_id) + .unwrap_or_default(); + if receiver_agent + .agent_path + .as_ref() + .is_some_and(AgentPath::is_root) + { + return Err(FunctionCallError::RespondToModel( + "root is not a spawned agent".to_string(), + )); + } + session + .send_event( + &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, + } + .into(), + ) + .await; + let status = match session + .services + .agent_control + .subscribe_status(agent_id) + .await + { + Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(err) => { + let status = session.services.agent_control.get_status(agent_id).await; + session + .send_event( + &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(), + receiver_agent_role: receiver_agent.agent_role.clone(), + status, + } + .into(), + ) + .await; + return Err(collab_agent_error(agent_id, err)); + } + }; + let result = session + .services + .agent_control + .close_agent(agent_id) + .await + .map_err(|err| collab_agent_error(agent_id, err)) + .map(|_| ()); + session + .send_event( + &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, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + result?; + + Ok(CloseAgentResult { + previous_status: status, + }) +} + +impl ToolHandler for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: CloseAgentArgs = parse_arguments(&arguments)?; - let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(agent_id) - .unwrap_or_default(); - if receiver_agent - .agent_path - .as_ref() - .is_some_and(AgentPath::is_root) - { - return Err(FunctionCallError::RespondToModel( - "root is not a spawned agent".to_string(), - )); - } - session - .send_event( - &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, - } - .into(), - ) - .await; - let status = match session - .services - .agent_control - .subscribe_status(agent_id) - .await - { - Ok(mut status_rx) => status_rx.borrow_and_update().clone(), - Err(err) => { - let status = session.services.agent_control.get_status(agent_id).await; - session - .send_event( - &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(), - receiver_agent_role: receiver_agent.agent_role.clone(), - status, - } - .into(), - ) - .await; - return Err(collab_agent_error(agent_id, err)); - } - }; - let result = session - .services - .agent_control - .close_agent(agent_id) - .await - .map_err(|err| collab_agent_error(agent_id, err)) - .map(|_| ()); - session - .send_event( - &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, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), - ) - .await; - result?; - - Ok(CloseAgentResult { - previous_status: status, - }) - } } #[derive(Debug, Deserialize)] diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs index 2077d6693a..608d7f2f62 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs @@ -8,7 +8,7 @@ use codex_tools::ToolSpec; pub(crate) struct Handler; -impl ToolHandler for Handler { +impl ToolExecutor for Handler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -19,10 +19,6 @@ impl ToolHandler for Handler { Some(create_followup_task_tool()) } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let arguments = function_arguments(invocation.payload.clone())?; let args: FollowupTaskArgs = parse_arguments(&arguments)?; @@ -35,3 +31,9 @@ impl ToolHandler for Handler { .await } } + +impl ToolHandler for Handler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs index b75f286751..527d5a29e7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -5,7 +5,7 @@ use codex_tools::ToolSpec; pub(crate) struct Handler; -impl ToolHandler for Handler { +impl ToolExecutor for Handler { type Output = ListAgentsResult; fn tool_name(&self) -> ToolName { @@ -16,10 +16,6 @@ impl ToolHandler for Handler { Some(create_list_agents_tool()) } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, @@ -44,6 +40,12 @@ impl ToolHandler for Handler { } } +impl ToolHandler for Handler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct ListAgentsArgs { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs index c9e2060702..71909afb7e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs @@ -8,7 +8,7 @@ use codex_tools::ToolSpec; pub(crate) struct Handler; -impl ToolHandler for Handler { +impl ToolExecutor for Handler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -19,10 +19,6 @@ impl ToolHandler for Handler { Some(create_send_message_tool()) } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let arguments = function_arguments(invocation.payload.clone())?; let args: SendMessageArgs = parse_arguments(&arguments)?; @@ -35,3 +31,9 @@ impl ToolHandler for Handler { .await } } + +impl ToolHandler for Handler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } +} 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 6642c5e4b0..1ab59cc3fe 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 @@ -24,7 +24,7 @@ impl Handler { } } -impl ToolHandler for Handler { +impl ToolExecutor for Handler { type Output = SpawnAgentResult; fn tool_name(&self) -> ToolName { @@ -35,193 +35,206 @@ impl ToolHandler for Handler { Some(create_spawn_agent_tool_v2(self.options.clone())) } + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(handle_spawn_agent(invocation)) + } +} + +async fn handle_spawn_agent( + invocation: ToolInvocation, +) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let fork_mode = args.fork_mode()?; + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); + + let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; + let prompt = render_input_preview(&initial_operation); + + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + session + .send_event( + &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(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), + } + .into(), + ) + .await; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { + reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?; + } else { + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + apply_role_to_config(&mut config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + } + apply_spawn_agent_service_tier( + &session, + &mut config, + turn.config.service_tier.as_deref(), + args.service_tier.as_deref(), + ) + .await?; + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + apply_spawn_agent_overrides(&mut config, child_depth); + + let spawn_source = thread_spawn_source( + session.conversation_id, + &turn.session_source, + child_depth, + role_name, + Some(args.task_name.clone()), + )?; + let result = Box::pin( + session.services.agent_control.spawn_agent_with_metadata( + config, + match (spawn_source.get_agent_path(), initial_operation) { + (Some(recipient), Op::UserInput { items, .. }) + if items + .iter() + .all(|item| matches!(item, UserInput::Text { .. })) => + { + Op::InterAgentCommunication { + communication: InterAgentCommunication::new( + turn.session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root), + recipient, + Vec::new(), + prompt.clone(), + /*trigger_turn*/ true, + ), + } + } + (_, initial_operation) => initial_operation, + }, + Some(spawn_source), + SpawnAgentOptions { + fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), + fork_mode, + environments: Some(turn.environments.to_selections()), + }, + ), + ) + .await + .map_err(collab_spawn_error); + let (new_thread_id, new_agent_metadata, status) = match &result { + Ok(spawned_agent) => ( + Some(spawned_agent.thread_id), + Some(spawned_agent.metadata.clone()), + spawned_agent.status.clone(), + ), + Err(_) => (None, None, AgentStatus::NotFound), + }; + let agent_snapshot = match new_thread_id { + Some(thread_id) => { + session + .services + .agent_control + .get_agent_config_snapshot(thread_id) + .await + } + None => None, + }; + let (new_agent_path, new_agent_nickname, new_agent_role) = + match (&agent_snapshot, new_agent_metadata) { + (Some(snapshot), _) => ( + snapshot.session_source.get_agent_path().map(String::from), + snapshot.session_source.get_nickname(), + snapshot.session_source.get_agent_role(), + ), + (None, Some(metadata)) => ( + metadata.agent_path.map(String::from), + metadata.agent_nickname, + metadata.agent_role, + ), + (None, None) => (None, None, None), + }; + let effective_model = agent_snapshot + .as_ref() + .map(|snapshot| snapshot.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()); + let effective_reasoning_effort = agent_snapshot + .as_ref() + .and_then(|snapshot| snapshot.reasoning_effort) + .unwrap_or(args.reasoning_effort.unwrap_or_default()); + let nickname = new_agent_nickname.clone(); + session + .send_event( + &turn, + CollabAgentSpawnEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + new_thread_id, + new_agent_nickname, + new_agent_role, + prompt, + model: effective_model, + reasoning_effort: effective_reasoning_effort, + status, + } + .into(), + ) + .await; + let _ = result?; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.session_telemetry.counter( + "codex.multi_agent.spawn", + /*inc*/ 1, + &[("role", role_tag)], + ); + let task_name = new_agent_path.ok_or_else(|| { + FunctionCallError::RespondToModel( + "spawned agent is missing a canonical task name".to_string(), + ) + })?; + + let hide_agent_metadata = turn.config.multi_agent_v2.hide_spawn_agent_metadata; + if hide_agent_metadata { + Ok(SpawnAgentResult::HiddenMetadata { task_name }) + } else { + Ok(SpawnAgentResult::WithNickname { + task_name, + nickname, + }) + } +} + +impl ToolHandler for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let fork_mode = args.fork_mode()?; - let role_name = args - .agent_type - .as_deref() - .map(str::trim) - .filter(|role| !role.is_empty()); - - let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; - let prompt = render_input_preview(&initial_operation); - - let session_source = turn.session_source.clone(); - let child_depth = next_thread_spawn_depth(&session_source); - session - .send_event( - &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(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), - } - .into(), - ) - .await; - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { - reject_full_fork_spawn_overrides( - role_name, - args.model.as_deref(), - args.reasoning_effort, - )?; - } else { - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; - apply_role_to_config(&mut config, role_name) - .await - .map_err(FunctionCallError::RespondToModel)?; - } - apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - apply_spawn_agent_overrides(&mut config, child_depth); - - let spawn_source = thread_spawn_source( - session.conversation_id, - &turn.session_source, - child_depth, - role_name, - Some(args.task_name.clone()), - )?; - let result = session - .services - .agent_control - .spawn_agent_with_metadata( - config, - match (spawn_source.get_agent_path(), initial_operation) { - (Some(recipient), Op::UserInput { items, .. }) - if items - .iter() - .all(|item| matches!(item, UserInput::Text { .. })) => - { - Op::InterAgentCommunication { - communication: InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - recipient, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ), - } - } - (_, initial_operation) => initial_operation, - }, - Some(spawn_source), - SpawnAgentOptions { - fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), - fork_mode, - environments: Some(turn.environments.to_selections()), - }, - ) - .await - .map_err(collab_spawn_error); - let (new_thread_id, new_agent_metadata, status) = match &result { - Ok(spawned_agent) => ( - Some(spawned_agent.thread_id), - Some(spawned_agent.metadata.clone()), - spawned_agent.status.clone(), - ), - Err(_) => (None, None, AgentStatus::NotFound), - }; - let agent_snapshot = match new_thread_id { - Some(thread_id) => { - session - .services - .agent_control - .get_agent_config_snapshot(thread_id) - .await - } - None => None, - }; - let (new_agent_path, new_agent_nickname, new_agent_role) = - match (&agent_snapshot, new_agent_metadata) { - (Some(snapshot), _) => ( - snapshot.session_source.get_agent_path().map(String::from), - snapshot.session_source.get_nickname(), - snapshot.session_source.get_agent_role(), - ), - (None, Some(metadata)) => ( - metadata.agent_path.map(String::from), - metadata.agent_nickname, - metadata.agent_role, - ), - (None, None) => (None, None, None), - }; - let effective_model = agent_snapshot - .as_ref() - .map(|snapshot| snapshot.model.clone()) - .unwrap_or_else(|| args.model.clone().unwrap_or_default()); - let effective_reasoning_effort = agent_snapshot - .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) - .unwrap_or(args.reasoning_effort.unwrap_or_default()); - let nickname = new_agent_nickname.clone(); - session - .send_event( - &turn, - CollabAgentSpawnEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - model: effective_model, - reasoning_effort: effective_reasoning_effort, - status, - } - .into(), - ) - .await; - let _ = result?; - let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); - turn.session_telemetry.counter( - "codex.multi_agent.spawn", - /*inc*/ 1, - &[("role", role_tag)], - ); - let task_name = new_agent_path.ok_or_else(|| { - FunctionCallError::RespondToModel( - "spawned agent is missing a canonical task name".to_string(), - ) - })?; - - let hide_agent_metadata = turn.config.multi_agent_v2.hide_spawn_agent_metadata; - if hide_agent_metadata { - Ok(SpawnAgentResult::HiddenMetadata { task_name }) - } else { - Ok(SpawnAgentResult::WithNickname { - task_name, - nickname, - }) - } - } } #[derive(Debug, Deserialize)] @@ -232,6 +245,7 @@ struct SpawnAgentArgs { agent_type: Option, model: Option, reasoning_effort: Option, + service_tier: Option, fork_turns: Option, fork_context: Option, } 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 15790e4cec..48cbebdea4 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 @@ -19,7 +19,7 @@ impl Handler { } } -impl ToolHandler for Handler { +impl ToolExecutor for Handler { type Output = WaitAgentResult; fn tool_name(&self) -> ToolName { @@ -30,10 +30,6 @@ impl ToolHandler for Handler { Some(create_wait_agent_tool_v2(self.options)) } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, @@ -101,6 +97,12 @@ impl ToolHandler for Handler { } } +impl ToolHandler for Handler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct WaitArgs { diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index a3c8aa1be9..42f03e4e30 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -3,6 +3,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::handlers::plan_spec::create_update_plan_tool; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_protocol::config_types::ModeKind; use codex_protocol::models::FunctionCallOutputPayload; @@ -43,7 +44,7 @@ impl ToolOutput for PlanToolOutput { } } -impl ToolHandler for PlanHandler { +impl ToolExecutor for PlanHandler { type Output = PlanToolOutput; fn tool_name(&self) -> ToolName { @@ -87,6 +88,8 @@ impl ToolHandler for PlanHandler { } } +impl ToolHandler for PlanHandler {} + fn parse_update_plan_arguments(arguments: &str) -> Result { serde_json::from_str::(arguments).map_err(|e| { FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e}")) diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 59c117905a..243ed52d15 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -8,13 +8,14 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments_with_base_path; use crate::tools::handlers::shell_spec::create_request_permissions_tool; use crate::tools::handlers::shell_spec::request_permissions_tool_description; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; pub struct RequestPermissionsHandler; -impl ToolHandler for RequestPermissionsHandler { +impl ToolExecutor for RequestPermissionsHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -75,3 +76,5 @@ impl ToolHandler for RequestPermissionsHandler { Ok(FunctionToolOutput::from_text(content, Some(true))) } } + +impl ToolHandler for RequestPermissionsHandler {} diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index c1dbdee316..9e76352aa7 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -34,6 +34,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_install_tool; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; #[derive(Default)] @@ -49,7 +50,7 @@ impl RequestPluginInstallHandler { } } -impl ToolHandler for RequestPluginInstallHandler { +impl ToolExecutor for RequestPluginInstallHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -192,6 +193,8 @@ impl ToolHandler for RequestPluginInstallHandler { } } +impl ToolHandler for RequestPluginInstallHandler {} + async fn maybe_persist_disabled_install_request( session: &crate::session::session::Session, turn: &crate::session::turn_context::TurnContext, diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index 583f62c0cc..ae0a2c7694 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -8,6 +8,7 @@ use crate::tools::handlers::request_user_input_spec::create_request_user_input_t use crate::tools::handlers::request_user_input_spec::normalize_request_user_input_args; use crate::tools::handlers::request_user_input_spec::request_user_input_tool_description; use crate::tools::handlers::request_user_input_spec::request_user_input_unavailable_message; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_protocol::config_types::ModeKind; use codex_protocol::request_user_input::RequestUserInputArgs; @@ -18,7 +19,7 @@ pub struct RequestUserInputHandler { pub available_modes: Vec, } -impl ToolHandler for RequestUserInputHandler { +impl ToolExecutor for RequestUserInputHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -82,6 +83,8 @@ impl ToolHandler for RequestUserInputHandler { } } +impl ToolHandler for RequestUserInputHandler {} + #[cfg(test)] #[path = "request_user_input_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 6f8ba4d632..e6ce6908f3 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -1,6 +1,5 @@ use codex_features::Feature; use codex_protocol::models::ShellCommandToolCallParams; -use codex_protocol::models::ShellToolCallParams; use serde_json::Value as JsonValue; use std::sync::Arc; @@ -9,8 +8,6 @@ use crate::exec_policy::ExecApprovalRequest; use crate::function_tool::FunctionCallError; use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; @@ -19,12 +16,7 @@ use crate::tools::handlers::apply_patch::intercept_apply_patch; use crate::tools::handlers::implicit_granted_permissions; use crate::tools::handlers::normalize_and_validate_additional_permissions; use crate::tools::handlers::parse_arguments; -use crate::tools::handlers::rewrite_function_arguments; -use crate::tools::handlers::updated_hook_command; -use crate::tools::hook_names::HookToolName; use crate::tools::orchestrator::ToolOrchestrator; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; use crate::tools::runtimes::shell::ShellRequest; use crate::tools::runtimes::shell::ShellRuntime; use crate::tools::runtimes::shell::ShellRuntimeBackend; @@ -33,36 +25,10 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ExecCommandSource; use codex_tools::ToolName; -mod container_exec; -mod local_shell; mod shell_command; -mod shell_handler; -pub use container_exec::ContainerExecHandler; -pub use local_shell::LocalShellHandler; pub use shell_command::ShellCommandHandler; pub(crate) use shell_command::ShellCommandHandlerOptions; -pub use shell_handler::ShellHandler; - -fn shell_function_payload_command(payload: &ToolPayload) -> Option { - let ToolPayload::Function { arguments } = payload else { - return None; - }; - - parse_arguments::(arguments) - .ok() - .map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command)) -} - -fn local_shell_payload_command(payload: &ToolPayload) -> Option { - let ToolPayload::LocalShell { params } = payload else { - return None; - }; - - Some(codex_shell_command::parse_command::shlex_join( - ¶ms.command, - )) -} fn shell_command_payload_command(payload: &ToolPayload) -> Option { let ToolPayload::Function { arguments } = payload else { @@ -88,53 +54,6 @@ struct RunExecLikeArgs { shell_runtime_backend: ShellRuntimeBackend, } -fn shell_function_pre_tool_use_payload(invocation: &ToolInvocation) -> Option { - shell_function_payload_command(&invocation.payload).map(|command| PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: serde_json::json!({ "command": command }), - }) -} - -fn rewrite_shell_function_updated_hook_input( - mut invocation: ToolInvocation, - updated_input: JsonValue, - tool_name: &str, -) -> Result { - let ToolPayload::Function { arguments } = invocation.payload else { - return Err(FunctionCallError::RespondToModel(format!( - "hook input rewrite received unsupported {tool_name} payload" - ))); - }; - let command = shlex::split(updated_hook_command(&updated_input)?).ok_or_else(|| { - FunctionCallError::RespondToModel( - "hook returned shell input with an invalid command string".to_string(), - ) - })?; - invocation.payload = ToolPayload::Function { - arguments: rewrite_function_arguments(&arguments, tool_name, |arguments| { - arguments.insert( - "command".to_string(), - JsonValue::Array(command.into_iter().map(JsonValue::String).collect()), - ); - })?, - }; - Ok(invocation) -} - -fn shell_function_post_tool_use_payload( - invocation: &ToolInvocation, - result: &FunctionToolOutput, -) -> Option { - let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; - let command = shell_function_payload_command(&invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id: invocation.call_id.clone(), - tool_input: serde_json::json!({ "command": command }), - tool_response, - }) -} - async fn run_exec_like(args: RunExecLikeArgs) -> Result { let RunExecLikeArgs { tool_name, @@ -289,15 +208,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result ShellRuntime::new(), - backend @ (ShellCommandClassic | ShellCommandZshFork) => { - ShellRuntime::for_shell_command(backend) - } - } - }; + let mut runtime = ShellRuntime::for_shell_command(shell_runtime_backend); let tool_ctx = ToolCtx { session: session.clone(), turn: turn.clone(), diff --git a/codex-rs/core/src/tools/handlers/shell/container_exec.rs b/codex-rs/core/src/tools/handlers/shell/container_exec.rs deleted file mode 100644 index cb09a51501..0000000000 --- a/codex-rs/core/src/tools/handlers/shell/container_exec.rs +++ /dev/null @@ -1,105 +0,0 @@ -use codex_protocol::models::ShellToolCallParams; -use codex_shell_command::is_safe_command::is_known_safe_command; -use codex_tools::ToolName; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments_with_base_path; -use crate::tools::handlers::resolve_workdir_base_path; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; -use crate::tools::registry::ToolHandler; -use crate::tools::runtimes::shell::ShellRuntimeBackend; - -use super::RunExecLikeArgs; -use super::rewrite_shell_function_updated_hook_input; -use super::run_exec_like; -use super::shell_function_post_tool_use_payload; -use super::shell_function_pre_tool_use_payload; -use super::shell_handler::ShellHandler; - -pub struct ContainerExecHandler; - -impl ToolHandler for ContainerExecHandler { - type Output = FunctionToolOutput; - - fn tool_name(&self) -> ToolName { - ToolName::plain("container.exec") - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { - let ToolPayload::Function { arguments } = &invocation.payload else { - return true; - }; - - serde_json::from_str::(arguments) - .map(|params| !is_known_safe_command(¶ms.command)) - .unwrap_or(true) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_function_pre_tool_use_payload(invocation) - } - - fn with_updated_hook_input( - &self, - invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - rewrite_shell_function_updated_hook_input(invocation, updated_input, "container.exec") - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - shell_function_post_tool_use_payload(invocation, result) - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - call_id, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "unsupported payload for container.exec handler".to_string(), - )); - } - }; - - let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; - let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; - let prefix_rule = params.prefix_rule.clone(); - let exec_params = - ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - run_exec_like(RunExecLikeArgs { - tool_name: ToolName::plain("container.exec"), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: params.additional_permissions.clone(), - prefix_rule, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } -} diff --git a/codex-rs/core/src/tools/handlers/shell/local_shell.rs b/codex-rs/core/src/tools/handlers/shell/local_shell.rs deleted file mode 100644 index 6265be4285..0000000000 --- a/codex-rs/core/src/tools/handlers/shell/local_shell.rs +++ /dev/null @@ -1,137 +0,0 @@ -use codex_shell_command::is_safe_command::is_known_safe_command; -use codex_tools::ToolName; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::updated_hook_command; -use crate::tools::hook_names::HookToolName; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; -use crate::tools::registry::ToolHandler; -use crate::tools::runtimes::shell::ShellRuntimeBackend; -use codex_tools::ToolSpec; - -use super::super::shell_spec::create_local_shell_tool; -use super::RunExecLikeArgs; -use super::local_shell_payload_command; -use super::run_exec_like; -use super::shell_handler::ShellHandler; - -#[derive(Default)] -pub struct LocalShellHandler { - include_spec: bool, -} - -impl LocalShellHandler { - pub(crate) fn new() -> Self { - Self { include_spec: true } - } -} - -impl ToolHandler for LocalShellHandler { - type Output = FunctionToolOutput; - - fn tool_name(&self) -> ToolName { - ToolName::plain("local_shell") - } - - fn spec(&self) -> Option { - self.include_spec.then(create_local_shell_tool) - } - - fn supports_parallel_tool_calls(&self) -> bool { - self.include_spec - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::LocalShell { .. }) - } - - async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { - let ToolPayload::LocalShell { params } = &invocation.payload else { - return true; - }; - - !is_known_safe_command(¶ms.command) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - local_shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: serde_json::json!({ "command": command }), - }) - } - - fn with_updated_hook_input( - &self, - mut invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - let command = updated_hook_command(&updated_input)?; - invocation.payload = match invocation.payload { - ToolPayload::LocalShell { mut params } => { - params.command = shlex::split(command).ok_or_else(|| { - FunctionCallError::RespondToModel( - "hook returned shell input with an invalid command string".to_string(), - ) - })?; - ToolPayload::LocalShell { params } - } - payload => payload, - }; - Ok(invocation) - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - let tool_response = - result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; - let command = local_shell_payload_command(&invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id: invocation.call_id.clone(), - tool_input: serde_json::json!({ "command": command }), - tool_response, - }) - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - call_id, - payload, - .. - } = invocation; - - let ToolPayload::LocalShell { params } = payload else { - return Err(FunctionCallError::RespondToModel( - "unsupported payload for local_shell handler".to_string(), - )); - }; - - let exec_params = - ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - run_exec_like(RunExecLikeArgs { - tool_name: ToolName::plain("local_shell"), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: None, - prefix_rule: None, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } -} diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index f5ecd80664..8d9170bbdf 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -1,6 +1,5 @@ use codex_protocol::ThreadId; use codex_protocol::models::ShellCommandToolCallParams; -use codex_shell_command::is_safe_command::is_known_safe_command; use codex_tools::ShellCommandBackendConfig; use codex_tools::ToolName; @@ -22,6 +21,7 @@ use crate::tools::handlers::updated_hook_command; use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use crate::tools::runtimes::shell::ShellRuntimeBackend; use codex_tools::ToolSpec; @@ -125,7 +125,7 @@ impl From for ShellCommandHandler { } } -impl ToolHandler for ShellCommandHandler { +impl ToolExecutor for ShellCommandHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -145,75 +145,6 @@ impl ToolHandler for ShellCommandHandler { self.options.is_some() } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { - let ToolPayload::Function { arguments } = &invocation.payload else { - return true; - }; - - serde_json::from_str::(arguments) - .map(|params| { - let use_login_shell = match Self::resolve_use_login_shell( - params.login, - invocation.turn.tools_config.allow_login_shell, - ) { - Ok(use_login_shell) => use_login_shell, - Err(_) => return true, - }; - let shell = invocation.session.user_shell(); - let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); - !is_known_safe_command(&command) - }) - .unwrap_or(true) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: serde_json::json!({ "command": command }), - }) - } - - fn with_updated_hook_input( - &self, - mut invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - let ToolPayload::Function { arguments } = invocation.payload else { - return Err(FunctionCallError::RespondToModel( - "hook input rewrite received unsupported shell_command payload".to_string(), - )); - }; - invocation.payload = ToolPayload::Function { - arguments: rewrite_function_string_argument( - &arguments, - "shell_command", - "command", - updated_hook_command(&updated_input)?, - )?, - }; - Ok(invocation) - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - let tool_response = - result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; - let command = shell_command_payload_command(&invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id: invocation.call_id.clone(), - tool_input: serde_json::json!({ "command": command }), - tool_response, - }) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, @@ -265,3 +196,53 @@ impl ToolHandler for ShellCommandHandler { .await } } + +impl ToolHandler for ShellCommandHandler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload { + tool_name: HookToolName::bash(), + tool_input: serde_json::json!({ "command": command }), + }) + } + + fn with_updated_hook_input( + &self, + mut invocation: ToolInvocation, + updated_input: serde_json::Value, + ) -> Result { + let ToolPayload::Function { arguments } = invocation.payload else { + return Err(FunctionCallError::RespondToModel( + "hook input rewrite received unsupported shell_command payload".to_string(), + )); + }; + invocation.payload = ToolPayload::Function { + arguments: rewrite_function_string_argument( + &arguments, + "shell_command", + "command", + updated_hook_command(&updated_input)?, + )?, + }; + Ok(invocation) + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + let tool_response = + result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + let command = shell_command_payload_command(&invocation.payload)?; + Some(PostToolUsePayload { + tool_name: HookToolName::bash(), + tool_use_id: invocation.call_id.clone(), + tool_input: serde_json::json!({ "command": command }), + tool_response, + }) + } +} diff --git a/codex-rs/core/src/tools/handlers/shell/shell_handler.rs b/codex-rs/core/src/tools/handlers/shell/shell_handler.rs deleted file mode 100644 index 39422734f5..0000000000 --- a/codex-rs/core/src/tools/handlers/shell/shell_handler.rs +++ /dev/null @@ -1,154 +0,0 @@ -use codex_protocol::ThreadId; -use codex_protocol::models::ShellToolCallParams; -use codex_shell_command::is_safe_command::is_known_safe_command; -use codex_tools::ToolName; - -use crate::exec::ExecCapturePolicy; -use crate::exec::ExecParams; -use crate::exec_env::create_env; -use crate::function_tool::FunctionCallError; -use crate::session::turn_context::TurnContext; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments_with_base_path; -use crate::tools::handlers::resolve_workdir_base_path; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; -use crate::tools::registry::ToolHandler; -use crate::tools::runtimes::shell::ShellRuntimeBackend; -use codex_tools::ToolSpec; - -use super::super::shell_spec::ShellToolOptions; -use super::super::shell_spec::create_shell_tool; -use super::RunExecLikeArgs; -use super::rewrite_shell_function_updated_hook_input; -use super::run_exec_like; -use super::shell_function_post_tool_use_payload; -use super::shell_function_pre_tool_use_payload; - -#[derive(Default)] -pub struct ShellHandler { - options: Option, -} - -impl ShellHandler { - pub(crate) fn new(options: ShellToolOptions) -> Self { - Self { - options: Some(options), - } - } - - pub(super) fn to_exec_params( - params: &ShellToolCallParams, - turn_context: &TurnContext, - thread_id: ThreadId, - ) -> ExecParams { - ExecParams { - command: params.command.clone(), - cwd: turn_context.resolve_path(params.workdir.clone()), - expiration: params.timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), - network: turn_context.network.clone(), - sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: params.justification.clone(), - arg0: None, - } - } -} - -impl ToolHandler for ShellHandler { - type Output = FunctionToolOutput; - - fn tool_name(&self) -> ToolName { - ToolName::plain("shell") - } - - fn spec(&self) -> Option { - self.options.map(create_shell_tool) - } - - fn supports_parallel_tool_calls(&self) -> bool { - self.options.is_some() - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { - let ToolPayload::Function { arguments } = &invocation.payload else { - return true; - }; - - serde_json::from_str::(arguments) - .map(|params| !is_known_safe_command(¶ms.command)) - .unwrap_or(true) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_function_pre_tool_use_payload(invocation) - } - - fn with_updated_hook_input( - &self, - invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - rewrite_shell_function_updated_hook_input(invocation, updated_input, "shell") - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - shell_function_post_tool_use_payload(invocation, result) - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - call_id, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "unsupported payload for shell handler".to_string(), - )); - } - }; - - let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; - let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; - let prefix_rule = params.prefix_rule.clone(); - let exec_params = - ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - run_exec_like(RunExecLikeArgs { - tool_name: ToolName::plain("shell"), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: params.additional_permissions.clone(), - prefix_rule, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } -} diff --git a/codex-rs/core/src/tools/handlers/shell_spec.rs b/codex-rs/core/src/tools/handlers/shell_spec.rs index dc46290bfa..43b78e3afe 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec.rs @@ -11,20 +11,11 @@ pub struct CommandToolOptions { pub exec_permission_approvals_enabled: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ShellToolOptions { - pub exec_permission_approvals_enabled: bool, -} - #[cfg(test)] pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec { create_exec_command_tool_with_environment_id(options, /*include_environment_id*/ false) } -pub fn create_local_shell_tool() -> ToolSpec { - ToolSpec::LocalShell {} -} - pub(crate) fn create_exec_command_tool_with_environment_id( options: CommandToolOptions, include_environment_id: bool, @@ -153,69 +144,6 @@ pub fn create_write_stdin_tool() -> ToolSpec { }) } -pub fn create_shell_tool(options: ShellToolOptions) -> ToolSpec { - let mut properties = BTreeMap::from([ - ( - "command".to_string(), - JsonSchema::array( - JsonSchema::string(/*description*/ None), - Some("The command to execute".to_string()), - ), - ), - ( - "workdir".to_string(), - JsonSchema::string(Some( - "The working directory to execute the command in".to_string(), - )), - ), - ( - "timeout_ms".to_string(), - JsonSchema::number(Some( - "The timeout for the command in milliseconds".to_string(), - )), - ), - ]); - properties.extend(create_approval_parameters( - options.exec_permission_approvals_enabled, - )); - - let description = if cfg!(windows) { - format!( - r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. - -Examples of valid command strings: - -- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"] -- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"] -- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"] -- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"] -- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"] -- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"] - -{}"#, - windows_shell_guidance() - ) - } else { - r#"Runs a shell command and returns its output. -- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. -- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."# - .to_string() - }; - - ToolSpec::Function(ResponsesApiTool { - name: "shell".to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec!["command".to_string()]), - Some(false.into()), - ), - output_schema: None, - }) -} - pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec { let mut properties = BTreeMap::from([ ( diff --git a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs index a219263e1a..4774815fb2 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs @@ -6,91 +6,6 @@ fn windows_shell_guidance_description() -> String { format!("\n\n{}", windows_shell_guidance()) } -#[test] -fn shell_tool_matches_expected_spec() { - let tool = create_shell_tool(ShellToolOptions { - exec_permission_approvals_enabled: false, - }); - - let description = if cfg!(windows) { - r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. - -Examples of valid command strings: - -- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"] -- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"] -- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"] -- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"] -- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"] -- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"# - .to_string() - + &windows_shell_guidance_description() - } else { - r#"Runs a shell command and returns its output. -- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. -- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."# - .to_string() - }; - - let properties = BTreeMap::from([ - ( - "command".to_string(), - JsonSchema::array(JsonSchema::string(/*description*/ None), Some("The command to execute".to_string())), - ), - ( - "workdir".to_string(), - JsonSchema::string(Some("The working directory to execute the command in".to_string())), - ), - ( - "timeout_ms".to_string(), - JsonSchema::number(Some("The timeout for the command in milliseconds".to_string())), - ), - ( - "sandbox_permissions".to_string(), - JsonSchema::string(Some( - "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." - .to_string(), - )), - ), - ( - "justification".to_string(), - JsonSchema::string(Some( - r#"Only set if sandbox_permissions is \"require_escalated\". - Request approval from the user to run this command outside the sandbox. - Phrased as a simple question that summarizes the purpose of the - command as it relates to the task at hand - e.g. 'Do you want to - fetch and pull the latest version of this git branch?'"# - .to_string(), - )), - ), - ( - "prefix_rule".to_string(), - JsonSchema::array(JsonSchema::string(/*description*/ None), Some( - r#"Only specify when sandbox_permissions is `require_escalated`. - Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future. - Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."# - .to_string(), - )), - ), - ]); - - assert_eq!( - tool, - ToolSpec::Function(ResponsesApiTool { - name: "shell".to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec!["command".to_string()]), - Some(false.into()) - ), - output_schema: None, - }) - ); -} - #[test] fn exec_command_tool_matches_expected_spec() { let tool = create_exec_command_tool(CommandToolOptions { @@ -224,77 +139,6 @@ fn write_stdin_tool_matches_expected_spec() { ); } -#[test] -fn shell_tool_with_request_permission_includes_additional_permissions() { - let tool = create_shell_tool(ShellToolOptions { - exec_permission_approvals_enabled: true, - }); - - let mut properties = BTreeMap::from([ - ( - "command".to_string(), - JsonSchema::array( - JsonSchema::string(/*description*/ None), - Some("The command to execute".to_string()), - ), - ), - ( - "workdir".to_string(), - JsonSchema::string(Some( - "The working directory to execute the command in".to_string(), - )), - ), - ( - "timeout_ms".to_string(), - JsonSchema::number(Some( - "The timeout for the command in milliseconds".to_string(), - )), - ), - ]); - properties.extend(create_approval_parameters( - /*exec_permission_approvals_enabled*/ true, - )); - - let description = if cfg!(windows) { - format!( - r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. - -Examples of valid command strings: - -- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"] -- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"] -- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"] -- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"] -- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"] -- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"] - -{}"#, - windows_shell_guidance() - ) - } else { - r#"Runs a shell command and returns its output. -- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. -- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."# - .to_string() - }; - - assert_eq!( - tool, - ToolSpec::Function(ResponsesApiTool { - name: "shell".to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec!["command".to_string()]), - Some(false.into()) - ), - output_schema: None, - }) - ); -} - #[test] fn request_permissions_tool_includes_full_permission_schema() { let tool = diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index ce97b8317e..9db561d577 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -16,7 +16,6 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; -use crate::tools::handlers::LocalShellHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::hook_names::HookToolName; use crate::tools::registry::ToolHandler; @@ -203,44 +202,6 @@ fn shell_command_handler_rejects_login_when_disallowed() { ); } -#[tokio::test] -async fn local_shell_pre_tool_use_payload_uses_joined_command() { - let payload = ToolPayload::LocalShell { - params: codex_protocol::models::ShellToolCallParams { - command: vec![ - "bash".to_string(), - "-lc".to_string(), - "printf hi".to_string(), - ], - workdir: None, - timeout_ms: None, - sandbox_permissions: None, - additional_permissions: None, - prefix_rule: None, - justification: None, - }, - }; - let (session, turn) = make_session_and_context().await; - let handler = LocalShellHandler::default(); - - assert_eq!( - handler.pre_tool_use_payload(&ToolInvocation { - session: session.into(), - turn: turn.into(), - cancellation_token: tokio_util::sync::CancellationToken::new(), - tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), - call_id: "call-41".to_string(), - tool_name: codex_tools::ToolName::plain("local_shell"), - source: crate::tools::context::ToolCallSource::Direct, - payload, - }), - Some(crate::tools::registry::PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: json!({ "command": "bash -lc 'printf hi'" }), - }) - ); -} - #[tokio::test] async fn shell_command_pre_tool_use_payload_uses_raw_command() { let payload = ToolPayload::Function { diff --git a/codex-rs/core/src/tools/handlers/test_sync.rs b/codex-rs/core/src/tools/handlers/test_sync.rs index 6ef39ffd17..091f0ffce7 100644 --- a/codex-rs/core/src/tools/handlers/test_sync.rs +++ b/codex-rs/core/src/tools/handlers/test_sync.rs @@ -14,6 +14,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::handlers::test_sync_spec::create_test_sync_tool; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -55,7 +56,7 @@ fn barrier_map() -> &'static tokio::sync::Mutex> { BARRIERS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())) } -impl ToolHandler for TestSyncHandler { +impl ToolExecutor for TestSyncHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> ToolName { @@ -104,6 +105,8 @@ impl ToolHandler for TestSyncHandler { } } +impl ToolHandler for TestSyncHandler {} + async fn wait_on_barrier(args: BarrierArgs) -> Result<(), FunctionCallError> { if args.participants == 0 { return Err(FunctionCallError::RespondToModel( diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 93a5fff4e2..7cd883aad1 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -3,8 +3,10 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::context::ToolSearchOutput; use crate::tools::handlers::tool_search_spec::create_tool_search_tool; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use crate::tools::tool_search_entry::ToolSearchEntry; +use crate::tools::tool_search_entry::ToolSearchInfo; use bm25::Document; use bm25::Language; use bm25::SearchEngine; @@ -16,10 +18,6 @@ use codex_tools::ToolName; use codex_tools::ToolSearchSourceInfo; use codex_tools::ToolSpec; use codex_tools::coalesce_loadable_tool_specs; -use std::collections::HashMap; - -const COMPUTER_USE_MCP_SERVER_NAME: &str = "computer-use"; -const COMPUTER_USE_TOOL_SEARCH_LIMIT: usize = 20; pub struct ToolSearchHandler { entries: Vec, @@ -28,10 +26,15 @@ pub struct ToolSearchHandler { } impl ToolSearchHandler { - pub(crate) fn new( - entries: Vec, - search_source_infos: Vec, - ) -> Self { + pub(crate) fn new(search_infos: Vec) -> Self { + let mut entries = Vec::with_capacity(search_infos.len()); + let mut search_source_infos = Vec::new(); + for search_info in search_infos { + entries.push(search_info.entry); + if let Some(source_info) = search_info.source_info { + search_source_infos.push(source_info); + } + } let documents: Vec> = entries .iter() .map(|entry| entry.search_text.clone()) @@ -49,7 +52,7 @@ impl ToolSearchHandler { } } -impl ToolHandler for ToolSearchHandler { +impl ToolExecutor for ToolSearchHandler { type Output = ToolSearchOutput; fn tool_name(&self) -> ToolName { @@ -88,8 +91,7 @@ impl ToolHandler for ToolSearchHandler { "query must not be empty".to_string(), )); } - let requested_limit = args.limit; - let limit = requested_limit.unwrap_or(TOOL_SEARCH_DEFAULT_LIMIT); + let limit = args.limit.unwrap_or(TOOL_SEARCH_DEFAULT_LIMIT); if limit == 0 { return Err(FunctionCallError::RespondToModel( @@ -101,55 +103,27 @@ impl ToolHandler for ToolSearchHandler { return Ok(ToolSearchOutput { tools: Vec::new() }); } - let tools = self.search(query, limit, requested_limit.is_none())?; + let tools = self.search(query, limit)?; Ok(ToolSearchOutput { tools }) } } +impl ToolHandler for ToolSearchHandler {} + impl ToolSearchHandler { fn search( &self, query: &str, limit: usize, - use_default_limit: bool, ) -> Result, FunctionCallError> { - let results = self.search_result_entries(query, limit, use_default_limit); - self.search_output_tools(results) - } - - fn search_result_entries( - &self, - query: &str, - limit: usize, - use_default_limit: bool, - ) -> Vec<&ToolSearchEntry> { - let mut results = self + let results = self .search_engine .search(query, limit) .into_iter() .map(|result| result.document.id) - .filter_map(|id| self.entries.get(id)) - .collect::>(); - if !use_default_limit { - return results; - } - - if results.iter().any(|entry| { - entry - .limit_bucket - .as_deref() - .is_some_and(|bucket| bucket == COMPUTER_USE_MCP_SERVER_NAME) - }) { - results = self - .search_engine - .search(query, COMPUTER_USE_TOOL_SEARCH_LIMIT) - .into_iter() - .map(|result| result.document.id) - .filter_map(|id| self.entries.get(id)) - .collect(); - } - limit_results_by_bucket(results) + .filter_map(|id| self.entries.get(id)); + self.search_output_tools(results) } fn search_output_tools<'a>( @@ -162,37 +136,11 @@ impl ToolSearchHandler { } } -fn limit_results_by_bucket(results: Vec<&ToolSearchEntry>) -> Vec<&ToolSearchEntry> { - results - .into_iter() - .scan(HashMap::<&str, usize>::new(), |counts, result| { - let Some(bucket) = result.limit_bucket.as_deref() else { - return Some(Some(result)); - }; - let count = counts.entry(bucket).or_default(); - if *count >= default_limit_for_bucket(bucket) { - Some(None) - } else { - *count += 1; - Some(Some(result)) - } - }) - .flatten() - .collect() -} - -fn default_limit_for_bucket(bucket: &str) -> usize { - if bucket == COMPUTER_USE_MCP_SERVER_NAME { - COMPUTER_USE_TOOL_SEARCH_LIMIT - } else { - TOOL_SEARCH_DEFAULT_LIMIT - } -} - #[cfg(test)] mod tests { use super::*; - use crate::tools::tool_search_entry::build_tool_search_entries; + use crate::tools::handlers::DynamicToolHandler; + use crate::tools::handlers::McpHandler; use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_tools::ResponsesApiNamespace; @@ -204,7 +152,7 @@ mod tests { #[test] fn mixed_search_results_coalesce_mcp_namespaces() { - let dynamic_tools = vec![DynamicToolSpec { + let dynamic_tools = [DynamicToolSpec { namespace: Some("codex_app".to_string()), name: "automation_update".to_string(), description: "Create, update, view, or delete recurring automations.".to_string(), @@ -218,11 +166,25 @@ mod tests { }), defer_loading: true, }]; - let mcp_tools = vec![ + let mcp_tools = [ tool_info("calendar", "create_event", "Create events"), tool_info("calendar", "list_events", "List events"), ]; - let handler = handler_from_tools(Some(&mcp_tools), &dynamic_tools); + let mut search_infos = mcp_tools + .iter() + .map(|tool| { + McpHandler::new(tool.clone()) + .search_info() + .expect("MCP handler should return search info") + }) + .collect::>(); + search_infos.extend(dynamic_tools.iter().map(|tool| { + DynamicToolHandler::new(tool) + .expect("dynamic tool should convert") + .search_info() + .expect("dynamic handler should return search info") + })); + let handler = ToolSearchHandler::new(search_infos); let results = [ &handler.entries[0], &handler.entries[2], @@ -290,107 +252,6 @@ mod tests { ); } - #[test] - fn computer_use_tool_search_uses_larger_limit() { - let tools = numbered_tools( - COMPUTER_USE_MCP_SERVER_NAME, - "computer use", - /*count*/ 100, - ); - let handler = handler_from_tools(Some(&tools), &[]); - - let results = handler.search_result_entries( - "computer use", - TOOL_SEARCH_DEFAULT_LIMIT, - /*use_default_limit*/ true, - ); - - assert_eq!(results.len(), COMPUTER_USE_TOOL_SEARCH_LIMIT); - assert!( - results - .iter() - .all(|entry| entry.limit_bucket.as_deref() == Some(COMPUTER_USE_MCP_SERVER_NAME)) - ); - - let explicit_results = handler.search_result_entries( - "computer use", - /*limit*/ 100, - /*use_default_limit*/ false, - ); - - assert_eq!(explicit_results.len(), 100); - } - - #[test] - fn non_computer_use_query_keeps_default_limit_with_computer_use_tools_installed() { - let mut tools = numbered_tools( - COMPUTER_USE_MCP_SERVER_NAME, - "computer use", - /*count*/ 100, - ); - tools.extend(numbered_tools( - "other-server", - "calendar", - /*count*/ 100, - )); - let handler = handler_from_tools(Some(&tools), &[]); - - let results = handler.search_result_entries( - "calendar", - TOOL_SEARCH_DEFAULT_LIMIT, - /*use_default_limit*/ true, - ); - - assert_eq!(results.len(), TOOL_SEARCH_DEFAULT_LIMIT); - assert!( - results - .iter() - .all(|entry| entry.limit_bucket.as_deref() == Some("other-server")) - ); - - let explicit_results = handler.search_result_entries( - "calendar", /*limit*/ 100, /*use_default_limit*/ false, - ); - - assert_eq!(explicit_results.len(), 100); - } - - #[test] - fn expanded_search_keeps_non_computer_use_servers_at_default_limit() { - let mut tools = numbered_tools( - COMPUTER_USE_MCP_SERVER_NAME, - "computer use", - /*count*/ 100, - ); - tools.extend(numbered_tools( - "other-server", - "computer use", - /*count*/ 100, - )); - let handler = handler_from_tools(Some(&tools), &[]); - - let results = handler.search_result_entries( - "computer use", - TOOL_SEARCH_DEFAULT_LIMIT, - /*use_default_limit*/ true, - ); - - assert!( - count_results_for_server(&results, COMPUTER_USE_MCP_SERVER_NAME) - <= COMPUTER_USE_TOOL_SEARCH_LIMIT - ); - assert!(count_results_for_server(&results, "other-server") <= TOOL_SEARCH_DEFAULT_LIMIT); - } - - fn numbered_tools(server_name: &str, description_prefix: &str, count: usize) -> Vec { - (0..count) - .map(|index| { - let tool_name = format!("tool_{index:03}"); - tool_info(server_name, &tool_name, description_prefix) - }) - .collect() - } - fn tool_info(server_name: &str, tool_name: &str, description_prefix: &str) -> ToolInfo { ToolInfo { server_name: server_name.to_string(), @@ -420,21 +281,4 @@ mod tests { plugin_display_names: Vec::new(), } } - - fn count_results_for_server(results: &[&ToolSearchEntry], server_name: &str) -> usize { - results - .iter() - .filter(|entry| entry.limit_bucket.as_deref() == Some(server_name)) - .count() - } - - fn handler_from_tools( - mcp_tools: Option<&[ToolInfo]>, - dynamic_tools: &[DynamicToolSpec], - ) -> ToolSearchHandler { - ToolSearchHandler::new( - build_tool_search_entries(mcp_tools, dynamic_tools), - Vec::new(), - ) - } } diff --git a/codex-rs/core/src/tools/handlers/unavailable_tool.rs b/codex-rs/core/src/tools/handlers/unavailable_tool.rs deleted file mode 100644 index e297374a7a..0000000000 --- a/codex-rs/core/src/tools/handlers/unavailable_tool.rs +++ /dev/null @@ -1,66 +0,0 @@ -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::registry::ToolHandler; -use codex_tools::ToolName; -use codex_tools::ToolSpec; - -pub struct UnavailableToolHandler { - tool_name: ToolName, - spec: Option, -} - -impl UnavailableToolHandler { - pub fn new(tool_name: ToolName, spec: ToolSpec) -> Self { - Self { - tool_name, - spec: Some(spec), - } - } - - pub fn without_spec(tool_name: ToolName) -> Self { - Self { - tool_name, - spec: None, - } - } -} - -pub(crate) fn unavailable_tool_message( - tool_name: impl std::fmt::Display, - next_step: &str, -) -> String { - format!( - "Tool `{tool_name}` is not currently available. It appeared in earlier tool calls in this conversation, but its implementation is not available in the current request. {next_step}" - ) -} - -impl ToolHandler for UnavailableToolHandler { - type Output = FunctionToolOutput; - - fn tool_name(&self) -> ToolName { - self.tool_name.clone() - } - - fn spec(&self) -> Option { - self.spec.clone() - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { payload, .. } = invocation; - - match payload { - ToolPayload::Function { .. } => Ok(FunctionToolOutput::from_text( - unavailable_tool_message( - &self.tool_name, - "Retry after the tool becomes available or ask the user to re-enable it.", - ), - Some(false), - )), - _ => Err(FunctionCallError::RespondToModel( - "unavailable tool handler received unsupported payload".to_string(), - )), - } - } -} diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 1311813262..cef066ad57 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -17,6 +17,7 @@ use crate::tools::handlers::updated_hook_command; use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use crate::unified_exec::ExecCommandRequest; use crate::unified_exec::UnifiedExecContext; @@ -26,7 +27,6 @@ use crate::unified_exec::generate_chunk_id; use codex_features::Feature; use codex_otel::SessionTelemetry; use codex_otel::TOOL_CALL_UNIFIED_EXEC_METRIC; -use codex_shell_command::is_safe_command::is_known_safe_command; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_utils_output_truncation::approx_token_count; @@ -68,7 +68,7 @@ impl ExecCommandHandler { } } -impl ToolHandler for ExecCommandHandler { +impl ToolExecutor for ExecCommandHandler { type Output = ExecCommandToolOutput; fn tool_name(&self) -> ToolName { @@ -89,76 +89,6 @@ impl ToolHandler for ExecCommandHandler { true } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { - let ToolPayload::Function { arguments } = &invocation.payload else { - tracing::error!( - "This should never happen, invocation payload is wrong: {:?}", - invocation.payload - ); - return true; - }; - - let Ok(params) = parse_arguments::(arguments) else { - return true; - }; - let command = match get_command( - ¶ms, - invocation.session.user_shell(), - &invocation.turn.tools_config.unified_exec_shell_mode, - invocation.turn.tools_config.allow_login_shell, - ) { - Ok(command) => command, - Err(_) => return true, - }; - !is_known_safe_command(&command) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - let ToolPayload::Function { arguments } = &invocation.payload else { - return None; - }; - - parse_arguments::(arguments) - .ok() - .map(|args| PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: serde_json::json!({ "command": args.cmd }), - }) - } - - fn with_updated_hook_input( - &self, - mut invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - let ToolPayload::Function { arguments } = invocation.payload else { - return Err(FunctionCallError::RespondToModel( - "hook input rewrite received unsupported exec_command payload".to_string(), - )); - }; - invocation.payload = ToolPayload::Function { - arguments: rewrite_function_string_argument( - &arguments, - "exec_command", - "cmd", - updated_hook_command(&updated_input)?, - )?, - }; - Ok(invocation) - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - post_unified_exec_tool_use_payload(invocation, result) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, @@ -364,6 +294,54 @@ impl ToolHandler for ExecCommandHandler { } } +impl ToolHandler for ExecCommandHandler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + let ToolPayload::Function { arguments } = &invocation.payload else { + return None; + }; + + parse_arguments::(arguments) + .ok() + .map(|args| PreToolUsePayload { + tool_name: HookToolName::bash(), + tool_input: serde_json::json!({ "command": args.cmd }), + }) + } + + fn with_updated_hook_input( + &self, + mut invocation: ToolInvocation, + updated_input: serde_json::Value, + ) -> Result { + let ToolPayload::Function { arguments } = invocation.payload else { + return Err(FunctionCallError::RespondToModel( + "hook input rewrite received unsupported exec_command payload".to_string(), + )); + }; + invocation.payload = ToolPayload::Function { + arguments: rewrite_function_string_argument( + &arguments, + "exec_command", + "cmd", + updated_hook_command(&updated_input)?, + )?, + }; + Ok(invocation) + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + post_unified_exec_tool_use_payload(invocation, result) + } +} + fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) { session_telemetry.counter( TOOL_CALL_UNIFIED_EXEC_METRIC, diff --git a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs index 3250a9d0a2..cc79615c14 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs @@ -4,6 +4,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::PostToolUsePayload; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use crate::unified_exec::WriteStdinRequest; use codex_protocol::protocol::EventMsg; @@ -30,7 +31,7 @@ struct WriteStdinArgs { pub struct WriteStdinHandler; -impl ToolHandler for WriteStdinHandler { +impl ToolExecutor for WriteStdinHandler { type Output = ExecCommandToolOutput; fn tool_name(&self) -> ToolName { @@ -41,22 +42,6 @@ impl ToolHandler for WriteStdinHandler { Some(create_write_stdin_tool()) } - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { - true - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - post_unified_exec_tool_use_payload(invocation, result) - } - async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, @@ -103,3 +88,17 @@ impl ToolHandler for WriteStdinHandler { Ok(response) } } + +impl ToolHandler for WriteStdinHandler { + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + post_unified_exec_tool_use_payload(invocation, result) + } +} diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 652e8c0eb6..3efbedc0ee 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -20,6 +20,7 @@ use crate::tools::handlers::parse_arguments; use crate::tools::handlers::resolve_tool_environment; use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::handlers::view_image_spec::create_view_image_tool; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -61,7 +62,7 @@ enum ViewImageDetail { Original, } -impl ToolHandler for ViewImageHandler { +impl ToolExecutor for ViewImageHandler { type Output = ViewImageOutput; fn tool_name(&self) -> ToolName { @@ -201,6 +202,8 @@ impl ToolHandler for ViewImageHandler { } } +impl ToolHandler for ViewImageHandler {} + pub struct ViewImageOutput { image_url: String, image_detail: Option, @@ -248,6 +251,7 @@ mod tests { use crate::tools::context::ToolInvocation; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::models::PermissionProfile; + use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde_json::json; use std::sync::Arc; @@ -276,7 +280,15 @@ mod tests { #[tokio::test] async fn handle_passes_sandbox_context_for_local_filesystem_reads() { let (session, mut turn) = make_session_and_context().await; - let image_path = turn.cwd.join("image.png"); + let image_dir = tempfile::tempdir().expect("create image temp dir"); + let image_cwd = image_dir.abs(); + turn.cwd = image_cwd.clone(); + turn.environments + .turn_environments + .first_mut() + .expect("default local turn environment") + .cwd = image_cwd.clone(); + let image_path = image_cwd.join("image.png"); std::fs::write(image_path.as_path(), b"not a real image").expect("write test image"); turn.permission_profile = PermissionProfile::read_only(); diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index 37a28b1255..a98d650688 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -229,7 +229,7 @@ async fn register_call_with_default_shell_trigger( "turn-1".to_string(), GuardianNetworkAccessTrigger { call_id: "call-1".to_string(), - tool_name: "shell".to_string(), + tool_name: "shell_command".to_string(), command: vec!["curl".to_string(), "https://example.com".to_string()], cwd: test_path_buf("/tmp").abs(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -249,7 +249,7 @@ async fn active_call_preserves_triggering_command_context() { let service = NetworkApprovalService::default(); let expected = GuardianNetworkAccessTrigger { call_id: "call-1".to_string(), - tool_name: "shell".to_string(), + tool_name: "shell_command".to_string(), command: vec!["curl".to_string(), "https://example.com".to_string()], cwd: test_path_buf("/repo").abs(), sandbox_permissions: SandboxPermissions::UseDefault, diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index d7fe22e4f4..4c79e4b168 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -22,7 +22,6 @@ use crate::tools::router::ToolCallSource; use crate::tools::router::ToolRouter; use codex_protocol::error::CodexErr; use codex_protocol::models::ResponseInputItem; -use codex_tools::ToolSpec; #[derive(Clone)] pub(crate) struct ToolCallRuntime { @@ -49,10 +48,6 @@ impl ToolCallRuntime { } } - pub(crate) fn find_spec(&self, tool_name: &codex_tools::ToolName) -> Option { - self.router.find_spec(tool_name) - } - pub(crate) fn create_diff_consumer( &self, tool_name: &codex_tools::ToolName, @@ -185,7 +180,7 @@ impl ToolCallRuntime { if call.tool_name.namespace.is_none() && matches!( call.tool_name.name.as_str(), - "shell" | "container.exec" | "local_shell" | "shell_command" | "unified_exec" + "shell_command" | "unified_exec" ) { format!("Wall time: {secs:.1} seconds\naborted by user") diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 235cb6fef3..85c346f48f 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -17,37 +18,30 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::flat_tool_name; -use crate::tools::handlers::extension_tools::BundledToolHandler; -use crate::tools::handlers::extension_tools::extension_tool_spec; +use crate::tools::handlers::extension_tools::ExtensionToolHandler; use crate::tools::hook_names::HookToolName; use crate::tools::tool_dispatch_trace::ToolDispatchTrace; +use crate::tools::tool_search_entry::ToolSearchInfo; use crate::util::error_or_panic; +use codex_extension_api::ExtensionToolExecutor; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::EventMsg; -use codex_tool_api::ToolBundle as ExtensionToolBundle; use codex_tools::ToolName; use codex_tools::ToolSpec; -use codex_utils_readiness::Readiness; use futures::future::BoxFuture; use serde_json::Value; use tracing::warn; pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>; -pub trait ToolHandler: Send + Sync { - type Output: ToolOutput + 'static; +pub use codex_tools::ToolExecutor; +pub use codex_tools::ToolExposure; - /// The concrete tool name handled by this handler instance. - fn tool_name(&self) -> ToolName; - - fn spec(&self) -> Option { +pub trait ToolHandler: ToolExecutor { + fn search_info(&self) -> Option { None } - fn supports_parallel_tool_calls(&self) -> bool { - false - } - fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!( payload, @@ -58,21 +52,10 @@ pub trait ToolHandler: Send + Sync { fn telemetry_tags( &self, _invocation: &ToolInvocation, - ) -> impl std::future::Future + Send { + ) -> impl Future + Send { async { Vec::new() } } - /// Returns `true` if the [ToolInvocation] *might* mutate the environment of the - /// user (through file system, OS operations, ...). - /// This function must remains defensive and return `true` if a doubt exist on the - /// exact effect of a ToolInvocation. - fn is_mutating( - &self, - _invocation: &ToolInvocation, - ) -> impl std::future::Future + Send { - async { false } - } - fn post_tool_use_payload( &self, _invocation: &ToolInvocation, @@ -103,13 +86,6 @@ pub trait ToolHandler: Send + Sync { fn create_diff_consumer(&self) -> Option> { None } - - /// Perform the actual [ToolInvocation] and returns a [ToolOutput] containing - /// the final output to return to the model. - fn handle( - &self, - invocation: ToolInvocation, - ) -> impl std::future::Future> + Send; } /// Consumes streamed argument diffs for a tool call and emits protocol events @@ -180,13 +156,23 @@ pub(crate) struct PostToolUsePayload { pub(crate) tool_response: Value, } -trait AnyToolHandler: Send + Sync { +/// Object-safe registry entry for heterogeneous tool handlers. +/// +/// Concrete handlers keep their typed `ToolExecutor::Output`; the registry +/// boxes that output only after typed hooks have run. +pub(crate) trait RegisteredTool: Send + Sync { + fn tool_name(&self) -> ToolName; + + fn spec(&self) -> Option; + + fn exposure(&self) -> ToolExposure; + + fn search_info(&self) -> Option; + fn supports_parallel_tool_calls(&self) -> bool; fn matches_kind(&self, payload: &ToolPayload) -> bool; - fn is_mutating<'a>(&'a self, invocation: &'a ToolInvocation) -> BoxFuture<'a, bool>; - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option; fn with_updated_hook_input( @@ -207,22 +193,34 @@ trait AnyToolHandler: Send + Sync { ) -> BoxFuture<'a, Result>; } -impl AnyToolHandler for T +impl RegisteredTool for T where T: ToolHandler, { + fn tool_name(&self) -> ToolName { + ToolExecutor::tool_name(self) + } + + fn spec(&self) -> Option { + ToolExecutor::spec(self) + } + + fn exposure(&self) -> ToolExposure { + ToolExecutor::exposure(self) + } + + fn search_info(&self) -> Option { + ToolHandler::search_info(self) + } + fn supports_parallel_tool_calls(&self) -> bool { - ToolHandler::supports_parallel_tool_calls(self) + ToolExecutor::supports_parallel_tool_calls(self) } fn matches_kind(&self, payload: &ToolPayload) -> bool { ToolHandler::matches_kind(self, payload) } - fn is_mutating<'a>(&'a self, invocation: &'a ToolInvocation) -> BoxFuture<'a, bool> { - Box::pin(ToolHandler::is_mutating(self, invocation)) - } - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { ToolHandler::pre_tool_use_payload(self, invocation) } @@ -252,7 +250,7 @@ where Box::pin(async move { let call_id = invocation.call_id.clone(); let payload = invocation.payload.clone(); - let output = self.handle(invocation.clone()).await?; + let output = ToolExecutor::handle(self, invocation.clone()).await?; let post_tool_use_payload = ToolHandler::post_tool_use_payload(self, &invocation, &output); Ok(AnyToolResult { @@ -265,12 +263,85 @@ where } } +pub(crate) fn override_tool_exposure( + handler: Arc, + exposure: ToolExposure, +) -> Arc { + if handler.exposure() == exposure { + return handler; + } + + Arc::new(ExposureOverride { handler, exposure }) +} + +struct ExposureOverride { + handler: Arc, + exposure: ToolExposure, +} + +impl RegisteredTool for ExposureOverride { + fn tool_name(&self) -> ToolName { + self.handler.tool_name() + } + + fn spec(&self) -> Option { + self.handler.spec() + } + + fn exposure(&self) -> ToolExposure { + self.exposure + } + + fn search_info(&self) -> Option { + self.handler.search_info() + } + + fn supports_parallel_tool_calls(&self) -> bool { + self.handler.supports_parallel_tool_calls() + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + self.handler.matches_kind(payload) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + self.handler.pre_tool_use_payload(invocation) + } + + fn with_updated_hook_input( + &self, + invocation: ToolInvocation, + updated_input: Value, + ) -> Result { + self.handler + .with_updated_hook_input(invocation, updated_input) + } + + fn telemetry_tags<'a>( + &'a self, + invocation: &'a ToolInvocation, + ) -> BoxFuture<'a, ToolTelemetryTags> { + self.handler.telemetry_tags(invocation) + } + + fn create_diff_consumer(&self) -> Option> { + self.handler.create_diff_consumer() + } + + fn handle_any<'a>( + &'a self, + invocation: ToolInvocation, + ) -> BoxFuture<'a, Result> { + self.handler.handle_any(invocation) + } +} + pub struct ToolRegistry { - handlers: HashMap>, + handlers: HashMap>, } impl ToolRegistry { - fn new(handlers: HashMap>) -> Self { + fn new(handlers: HashMap>) -> Self { Self { handlers } } @@ -285,13 +356,17 @@ impl ToolRegistry { T: ToolHandler + 'static, { let name = handler.tool_name(); - Self::new(HashMap::from([(name, handler as Arc)])) + Self::new(HashMap::from([(name, handler as Arc)])) } - fn handler(&self, name: &ToolName) -> Option> { + fn handler(&self, name: &ToolName) -> Option> { self.handlers.get(name).map(Arc::clone) } + pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { + self.handlers.get(name).map(|handler| handler.exposure()) + } + #[cfg(test)] pub(crate) fn has_handler(&self, name: &ToolName) -> bool { self.handler(name).is_some() @@ -305,7 +380,8 @@ impl ToolRegistry { } pub(crate) fn supports_parallel_tool_calls(&self, name: &ToolName) -> Option { - Some(self.handler(name)?.supports_parallel_tool_calls()) + let handler = self.handler(name)?; + Some(handler.supports_parallel_tool_calls()) } #[expect( @@ -424,7 +500,6 @@ impl ToolRegistry { } } - let is_mutating = handler.is_mutating(&invocation).await; let response_cell = tokio::sync::Mutex::new(None); let invocation_for_tool = invocation.clone(); let log_payload = invocation.payload.log_payload(); @@ -440,11 +515,6 @@ impl ToolRegistry { let handler = handler.clone(); let response_cell = &response_cell; async move { - if is_mutating { - tracing::trace!("waiting for tool gate"); - invocation_for_tool.turn.tool_call_gate.wait_ready().await; - tracing::trace!("tool gate released"); - } match handler.handle_any(invocation_for_tool).await { Ok(result) => { let preview = result.result.log_preview(); @@ -552,26 +622,19 @@ impl ToolRegistry { } pub struct ToolRegistryBuilder { - handlers: HashMap>, + handlers: HashMap>, specs: Vec, - code_mode_enabled: bool, } impl ToolRegistryBuilder { - pub fn new(code_mode_enabled: bool) -> Self { + pub fn new() -> Self { Self { handlers: HashMap::new(), specs: Vec::new(), - code_mode_enabled, } } pub(crate) fn push_spec(&mut self, spec: ToolSpec) { - let spec = if self.code_mode_enabled { - codex_tools::augment_tool_spec_for_code_mode(spec) - } else { - spec - }; self.specs.push(spec); } @@ -579,44 +642,43 @@ impl ToolRegistryBuilder { where H: ToolHandler + 'static, { + self.register_tool(handler); + } + + pub(crate) fn register_tool(&mut self, handler: Arc) { + self.register_tool_internal(handler, /*include_spec*/ true); + } + + pub(crate) fn register_tool_without_spec(&mut self, handler: Arc) { + self.register_tool_internal(handler, /*include_spec*/ false); + } + + fn register_tool_internal(&mut self, handler: Arc, include_spec: bool) { let name = handler.tool_name(); if self.handlers.contains_key(&name) { error_or_panic(format!("handler for tool {name} already registered")); return; } - if let Some(spec) = handler.spec() { + if include_spec + && handler.exposure().is_direct() + && let Some(spec) = handler.spec() + { self.push_spec(spec); } - let handler: Arc = handler; self.handlers.insert(name, handler); } - pub fn register_tool_bundle(&mut self, bundle: ExtensionToolBundle) { - let tool_name = ToolName::plain(bundle.tool_name()); + pub fn register_extension_tool_executor(&mut self, executor: Arc) { + let tool_name = executor.tool_name(); if self.handlers.contains_key(&tool_name) { warn!("Skipping extension tool `{tool_name}`: handler already registered"); return; } - let spec = match extension_tool_spec(bundle.spec()) { - Ok(spec) => spec, - Err(error) => { - error_or_panic(format!( - "failed to convert extension tool `{tool_name}` to a host spec: {error}" - )); - return; - } - }; - self.push_spec(spec.clone()); - - let handler: Arc = Arc::new(BundledToolHandler::new(bundle, spec)); - self.handlers.insert(tool_name, handler); - } - - pub(crate) fn specs(&self) -> &[ToolSpec] { - &self.specs + let handler: Arc = Arc::new(ExtensionToolHandler::new(executor)); + self.register_tool_internal(handler, /*include_spec*/ true); } pub fn build(self) -> (Vec, ToolRegistry) { @@ -631,7 +693,6 @@ fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &ToolName) -> _ => format!("unsupported call: {tool_name}"), } } - #[cfg(test)] #[path = "registry_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index d6acb80fb4..e27dd8fd31 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -8,7 +8,7 @@ struct TestHandler { tool_name: codex_tools::ToolName, } -impl ToolHandler for TestHandler { +impl ToolExecutor for TestHandler { type Output = crate::tools::context::FunctionToolOutput; fn tool_name(&self) -> codex_tools::ToolName { @@ -23,6 +23,8 @@ impl ToolHandler for TestHandler { } } +impl ToolHandler for TestHandler {} + #[test] fn handler_looks_up_namespaced_aliases_explicitly() { let namespace = "mcp__codex_apps__gmail"; @@ -31,10 +33,10 @@ fn handler_looks_up_namespaced_aliases_explicitly() { let namespaced_name = codex_tools::ToolName::namespaced(namespace, tool_name); let plain_handler = Arc::new(TestHandler { tool_name: plain_name.clone(), - }) as Arc; + }) as Arc; let namespaced_handler = Arc::new(TestHandler { tool_name: namespaced_name.clone(), - }) as Arc; + }) as Arc; let registry = ToolRegistry::new(HashMap::from([ (plain_name.clone(), Arc::clone(&plain_handler)), (namespaced_name.clone(), Arc::clone(&namespaced_handler)), @@ -63,16 +65,13 @@ fn handler_looks_up_namespaced_aliases_explicitly() { } #[test] -fn register_handler_adds_handler_and_augments_specs_for_code_mode() { - let mut builder = ToolRegistryBuilder::new(/*code_mode_enabled*/ true); +fn register_handler_adds_handler_and_spec() { + let mut builder = ToolRegistryBuilder::new(); builder.register_handler(Arc::new(GetGoalHandler)); let (specs, registry) = builder.build(); assert_eq!(specs.len(), 1); - assert_eq!( - specs[0], - codex_tools::augment_tool_spec_for_code_mode(create_get_goal_tool()) - ); + assert_eq!(specs[0], create_get_goal_tool()); assert!(registry.has_handler(&codex_tools::ToolName::plain(GET_GOAL_TOOL_NAME))); } diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index ed3f1a720b..23f0207734 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -1,5 +1,4 @@ use crate::function_tool::FunctionCallError; -use crate::sandboxing::SandboxPermissions; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::context::SharedTurnDiffTracker; @@ -7,21 +6,18 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::registry::AnyToolResult; use crate::tools::registry::ToolArgumentDiffConsumer; +use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistry; use crate::tools::spec::build_specs_with_discoverable_tools; +use codex_extension_api::ExtensionToolExecutor; use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_protocol::models::LocalShellAction; use codex_protocol::models::ResponseItem; use codex_protocol::models::SearchToolCallParams; -use codex_protocol::models::ShellToolCallParams; -use codex_tool_api::ToolBundle as ExtensionToolBundle; use codex_tools::DiscoverableTool; -use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; -use std::collections::HashSet; use std::sync::Arc; use tokio_util::sync::CancellationToken; use tracing::instrument; @@ -37,16 +33,14 @@ pub struct ToolCall { pub struct ToolRouter { registry: ToolRegistry, - specs: Vec, model_visible_specs: Vec, } pub(crate) struct ToolRouterParams<'a> { pub(crate) mcp_tools: Option>, pub(crate) deferred_mcp_tools: Option>, - pub(crate) unavailable_called_tools: Vec, pub(crate) discoverable_tools: Option>, - pub(crate) extension_tool_bundles: Vec, + pub(crate) extension_tool_executors: Vec>, pub(crate) dynamic_tools: &'a [DynamicToolSpec], } @@ -55,79 +49,34 @@ impl ToolRouter { let ToolRouterParams { mcp_tools, deferred_mcp_tools, - unavailable_called_tools, discoverable_tools, - extension_tool_bundles, + extension_tool_executors, dynamic_tools, } = params; let builder = build_specs_with_discoverable_tools( config, mcp_tools, deferred_mcp_tools, - unavailable_called_tools, discoverable_tools, - &extension_tool_bundles, + &extension_tool_executors, dynamic_tools, ); let (specs, registry) = builder.build(); - let deferred_dynamic_tools = dynamic_tools - .iter() - .filter(|tool| tool.defer_loading) - .map(|tool| ToolName::new(tool.namespace.clone(), tool.name.clone())) - .collect::>(); let model_visible_specs = specs - .iter() - .filter_map(|spec| { - if config.code_mode_only_enabled - && codex_code_mode::is_code_mode_nested_tool(spec.name()) - { - return None; - } - - filter_deferred_dynamic_tool_spec(spec.clone(), &deferred_dynamic_tools) - }) + .into_iter() + .filter(|spec| !is_hidden_by_code_mode_only(config, ®istry, spec)) .collect(); Self { registry, - specs, model_visible_specs, } } - pub fn specs(&self) -> Vec { - self.specs.clone() - } - pub fn model_visible_specs(&self) -> Vec { self.model_visible_specs.clone() } - pub fn find_spec(&self, tool_name: &ToolName) -> Option { - self.specs.iter().find_map(|spec| match spec { - ToolSpec::Function(tool) - if tool_name.namespace.is_none() && tool.name == tool_name.name => - { - Some(spec.clone()) - } - ToolSpec::Freeform(tool) - if tool_name.namespace.is_none() && tool.name == tool_name.name => - { - Some(spec.clone()) - } - ToolSpec::Namespace(namespace) => namespace.tools.iter().find_map(|tool| match tool { - ResponsesApiNamespaceTool::Function(tool) - if tool_name.namespace.as_deref() == Some(namespace.name.as_str()) - && tool.name == tool_name.name => - { - Some(ToolSpec::Function(tool.clone())) - } - _ => None, - }), - _ => None, - }) - } - pub(crate) fn create_diff_consumer( &self, tool_name: &ToolName, @@ -187,35 +136,6 @@ impl ToolRouter { call_id, payload: ToolPayload::Custom { input }, })), - ResponseItem::LocalShellCall { - id, - call_id, - action, - .. - } => { - let call_id = call_id - .or(id) - .ok_or(FunctionCallError::MissingLocalShellCallId)?; - - match action { - LocalShellAction::Exec(exec) => { - let params = ShellToolCallParams { - command: exec.command, - workdir: exec.working_directory, - timeout_ms: exec.timeout_ms, - sandbox_permissions: Some(SandboxPermissions::UseDefault), - additional_permissions: None, - prefix_rule: None, - justification: None, - }; - Ok(Some(ToolCall { - tool_name: ToolName::plain("local_shell"), - call_id, - payload: ToolPayload::LocalShell { params }, - })) - } - } - } _ => Ok(None), } } @@ -251,7 +171,22 @@ impl ToolRouter { } } -pub(crate) fn extension_tool_bundles(session: &Session) -> Vec { +fn is_hidden_by_code_mode_only( + config: &ToolsConfig, + registry: &ToolRegistry, + spec: &ToolSpec, +) -> bool { + if !config.code_mode_only_enabled || !codex_code_mode::is_code_mode_nested_tool(spec.name()) { + return false; + } + + let exposure = registry + .tool_exposure(&ToolName::plain(spec.name())) + .unwrap_or(ToolExposure::Direct); + exposure != ToolExposure::DirectModelOnly +} + +pub(crate) fn extension_tool_executors(session: &Session) -> Vec> { session .services .extensions @@ -266,38 +201,6 @@ pub(crate) fn extension_tool_bundles(session: &Session) -> Vec, -) -> Option { - if deferred_dynamic_tools.is_empty() { - return Some(spec); - } - - match spec { - ToolSpec::Function(tool) => { - if deferred_dynamic_tools.contains(&ToolName::plain(tool.name.as_str())) { - None - } else { - Some(ToolSpec::Function(tool)) - } - } - ToolSpec::Namespace(mut namespace) => { - let namespace_name = namespace.name.clone(); - namespace.tools.retain(|tool| match tool { - ResponsesApiNamespaceTool::Function(tool) => !deferred_dynamic_tools.contains( - &ToolName::namespaced(namespace_name.as_str(), tool.name.as_str()), - ), - }); - if namespace.tools.is_empty() { - None - } else { - Some(ToolSpec::Namespace(namespace)) - } - } - spec => Some(spec), - } -} #[cfg(test)] #[path = "router_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 01b828bf6f..fe2183aac5 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -7,15 +7,14 @@ use crate::turn_diff_tracker::TurnDiffTracker; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::FunctionToolSpec; -use codex_extension_api::ToolBundle; -use codex_extension_api::ToolExecutor; -use codex_extension_api::ToolFuture; +use codex_extension_api::ExtensionToolExecutor; +use codex_extension_api::ExtensionToolOutput; +use codex_extension_api::ResponsesApiTool; +use codex_extension_api::ToolCall as ExtensionToolCall; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; -use codex_tool_api::ToolCall as ExtensionToolCall; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -27,7 +26,7 @@ use super::ToolCall; use super::ToolCallSource; use super::ToolRouter; use super::ToolRouterParams; -use super::extension_tool_bundles; +use super::extension_tool_executors; struct ExtensionEchoContributor; @@ -36,38 +35,46 @@ impl codex_extension_api::ToolContributor for ExtensionEchoContributor { &self, _session_store: &ExtensionData, _thread_store: &ExtensionData, - ) -> Vec { - vec![ToolBundle::new( - FunctionToolSpec { - name: "extension_echo".to_string(), - description: "Echoes arguments through an extension tool.".to_string(), - strict: true, - parameters: json!({ - "type": "object", - "properties": { - "message": { "type": "string" }, - }, - "required": ["message"], - "additionalProperties": false, - }), - }, - Arc::new(ExtensionEchoExecutor), - )] + ) -> Vec> { + vec![Arc::new(ExtensionEchoExecutor)] } } struct ExtensionEchoExecutor; -impl ToolExecutor for ExtensionEchoExecutor { - fn execute<'a>(&'a self, call: ExtensionToolCall) -> ToolFuture<'a> { +impl ExtensionToolExecutor for ExtensionEchoExecutor { + fn tool_name(&self) -> ToolName { + ToolName::plain("extension_echo") + } + + fn spec(&self) -> Option { + Some(ToolSpec::Function(ResponsesApiTool { + name: "extension_echo".to_string(), + description: "Echoes arguments through an extension tool.".to_string(), + strict: true, + parameters: codex_extension_api::parse_tool_input_schema(&json!({ + "type": "object", + "properties": { + "message": { "type": "string" }, + }, + "required": ["message"], + "additionalProperties": false, + })) + .expect("extension schema should parse"), + output_schema: None, + defer_loading: None, + })) + } + + fn handle(&self, call: ExtensionToolCall) -> codex_extension_api::ExtensionToolFuture<'_> { Box::pin(async move { - let arguments: serde_json::Value = - serde_json::from_str(&call.arguments).expect("test arguments should parse"); - Ok(json!({ + let arguments: serde_json::Value = serde_json::from_str(call.function_arguments()?) + .expect("test arguments should parse"); + Ok(ExtensionToolOutput::new(json!({ "arguments": arguments, "callId": call.call_id.clone(), "ok": true, - })) + }))) }) } } @@ -97,14 +104,13 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow ToolRouterParams { deferred_mcp_tools: None, mcp_tools: Some(mcp_tools), - unavailable_called_tools: Vec::new(), discoverable_tools: None, - extension_tool_bundles: Vec::new(), + extension_tool_executors: Vec::new(), dynamic_tools: turn.dynamic_tools.as_slice(), }, ); - let parallel_tool_name = ["shell", "local_shell", "exec_command", "shell_command"] + let parallel_tool_name = ["exec_command", "shell_command"] .into_iter() .find(|name| { router.tool_supports_parallel(&ToolCall { @@ -177,9 +183,8 @@ async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { "query_with_delay", ), ]), - unavailable_called_tools: Vec::new(), discoverable_tools: None, - extension_tool_bundles: Vec::new(), + extension_tool_executors: Vec::new(), dynamic_tools: turn.dynamic_tools.as_slice(), }, ); @@ -213,9 +218,8 @@ async fn tools_without_handlers_do_not_support_parallel() -> anyhow::Result<()> ToolRouterParams { deferred_mcp_tools: None, mcp_tools: None, - unavailable_called_tools: Vec::new(), discoverable_tools: None, - extension_tool_bundles: Vec::new(), + extension_tool_executors: Vec::new(), dynamic_tools: turn.dynamic_tools.as_slice(), }, ); @@ -232,7 +236,7 @@ async fn tools_without_handlers_do_not_support_parallel() -> anyhow::Result<()> } #[tokio::test] -async fn model_visible_specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> { +async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> { let (_, turn) = make_session_and_context().await; let hidden_tool = "hidden_dynamic_tool"; let visible_tool = "visible_dynamic_tool"; @@ -266,22 +270,12 @@ async fn model_visible_specs_filter_deferred_dynamic_tools() -> anyhow::Result<( ToolRouterParams { deferred_mcp_tools: None, mcp_tools: None, - unavailable_called_tools: Vec::new(), discoverable_tools: None, - extension_tool_bundles: Vec::new(), + extension_tool_executors: Vec::new(), dynamic_tools: &dynamic_tools, }, ); - assert!( - router - .find_spec(&ToolName::namespaced("codex_app", hidden_tool)) - .is_some() - ); - assert_eq!( - namespace_function_names(&router.specs(), "codex_app"), - vec![hidden_tool.to_string(), visible_tool.to_string()] - ); assert_eq!( namespace_function_names(&router.model_visible_specs(), "codex_app"), vec![visible_tool.to_string()] @@ -324,7 +318,7 @@ fn mcp_tool_info( } #[tokio::test] -async fn extension_tool_bundles_are_model_visible_and_dispatchable() -> anyhow::Result<()> { +async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow::Result<()> { let (mut session, turn) = make_session_and_context().await; session.services.extensions = extension_tool_test_registry(); @@ -333,19 +327,12 @@ async fn extension_tool_bundles_are_model_visible_and_dispatchable() -> anyhow:: ToolRouterParams { deferred_mcp_tools: None, mcp_tools: None, - unavailable_called_tools: Vec::new(), discoverable_tools: None, - extension_tool_bundles: extension_tool_bundles(&session), + extension_tool_executors: extension_tool_executors(&session), dynamic_tools: turn.dynamic_tools.as_slice(), }, ); - assert!( - router - .find_spec(&ToolName::plain("extension_echo")) - .is_some(), - "expected extension-provided tool spec to be registered" - ); assert!( router .model_visible_specs() @@ -362,7 +349,6 @@ async fn extension_tool_bundles_are_model_visible_and_dispatchable() -> anyhow:: call_id: "call-extension".to_string(), })? .expect("function_call should produce a tool call"); - let result = router .dispatch_tool_call_with_code_mode_result( Arc::new(session), @@ -414,7 +400,6 @@ fn namespace_function_names(specs: &[ToolSpec], namespace_name: &str) -> Vec None, diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 4f6eb620aa..ad17c47596 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -62,19 +62,8 @@ pub struct ShellRequest { } /// Selects `ShellRuntime` behavior for different callers. -/// -/// Note: `Generic` is not the same as `ShellCommandClassic`. -/// `Generic` means "no `shell_command`-specific backend behavior" (used by the -/// generic `shell` tool path). The `ShellCommand*` variants are only for the -/// `shell_command` tool family. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ShellRuntimeBackend { - /// Tool-agnostic/default runtime path. - /// - /// Uses the normal `ShellRuntime` execution flow without enabling any - /// `shell_command`-specific backend selection. - #[default] - Generic, /// Legacy backend for the `shell_command` tool. /// /// Keeps `shell_command` on the standard shell runtime flow without the @@ -88,7 +77,6 @@ pub(crate) enum ShellRuntimeBackend { ShellCommandZshFork, } -#[derive(Default)] pub struct ShellRuntime { backend: ShellRuntimeBackend, } @@ -102,12 +90,6 @@ pub(crate) struct ApprovalKey { } impl ShellRuntime { - pub fn new() -> Self { - Self { - backend: ShellRuntimeBackend::Generic, - } - } - pub(crate) fn for_shell_command(backend: ShellRuntimeBackend) -> Self { Self { backend } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index fc21ff3375..1eca4d1dd6 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,26 +1,18 @@ use crate::shell::Shell; use crate::shell::ShellType; -use crate::tools::flat_tool_name; use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; use crate::tools::registry::ToolRegistryBuilder; use crate::tools::spec_plan::build_tool_registry_builder; -use crate::tools::spec_plan_types::ToolNamespace; use crate::tools::spec_plan_types::ToolRegistryBuildParams; +use codex_extension_api::ExtensionToolExecutor; use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_tool_api::ToolBundle as ExtensionToolBundle; -use codex_tools::AdditionalProperties; use codex_tools::DiscoverableTool; -use codex_tools::JsonSchema; -use codex_tools::ResponsesApiTool; -use codex_tools::ToolName; use codex_tools::ToolUserShellType; use codex_tools::ToolsConfig; -use std::collections::HashMap; -use std::collections::HashSet; use std::sync::Arc; pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType { @@ -33,44 +25,14 @@ pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType { } } -struct McpToolPlanInputs { - mcp_tools: Vec, - tool_namespaces: HashMap, -} - -fn map_mcp_tools_for_plan(mcp_tools: &[ToolInfo]) -> McpToolPlanInputs { - McpToolPlanInputs { - mcp_tools: mcp_tools.to_vec(), - tool_namespaces: mcp_tools - .iter() - .map(|tool| { - ( - tool.callable_namespace.clone(), - ToolNamespace { - name: tool.callable_namespace.clone(), - description: tool.namespace_description.clone(), - }, - ) - }) - .collect(), - } -} - pub(crate) fn build_specs_with_discoverable_tools( config: &ToolsConfig, mcp_tools: Option>, deferred_mcp_tools: Option>, - unavailable_called_tools: Vec, discoverable_tools: Option>, - extension_tool_bundles: &[ExtensionToolBundle], + extension_tool_executors: &[Arc], dynamic_tools: &[DynamicToolSpec], ) -> ToolRegistryBuilder { - use crate::tools::handlers::UnavailableToolHandler; - use crate::tools::handlers::unavailable_tool_message; - use crate::tools::tool_search_entry::build_tool_search_entries_for_config; - - let mcp_tool_plan_inputs = mcp_tools.as_deref().map(map_mcp_tools_for_plan); - let deferred_mcp_tool_sources = deferred_mcp_tools.as_deref(); let default_agent_type_description = crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new()); let min_wait_timeout_ms = if config.multi_agent_v2 { @@ -83,28 +45,13 @@ pub(crate) fn build_specs_with_discoverable_tools( }; let default_wait_timeout_ms = DEFAULT_WAIT_TIMEOUT_MS.clamp(min_wait_timeout_ms, MAX_WAIT_TIMEOUT_MS); - let deferred_dynamic_tools = dynamic_tools - .iter() - .filter(|tool| tool.defer_loading && (config.namespace_tools || tool.namespace.is_none())) - .cloned() - .collect::>(); - let tool_search_entries = build_tool_search_entries_for_config( - config, - deferred_mcp_tool_sources, - &deferred_dynamic_tools, - ); - let mut builder = build_tool_registry_builder( + build_tool_registry_builder( config, ToolRegistryBuildParams { - mcp_tools: mcp_tool_plan_inputs - .as_ref() - .map(|inputs| inputs.mcp_tools.as_slice()), - deferred_mcp_tools: deferred_mcp_tool_sources, - tool_namespaces: mcp_tool_plan_inputs - .as_ref() - .map(|inputs| &inputs.tool_namespaces), + mcp_tools: mcp_tools.as_deref(), + deferred_mcp_tools: deferred_mcp_tools.as_deref(), discoverable_tools: discoverable_tools.as_deref(), - extension_tool_bundles, + extension_tool_executors, dynamic_tools, default_agent_type_description: &default_agent_type_description, wait_agent_timeouts: WaitAgentTimeoutOptions { @@ -112,44 +59,8 @@ pub(crate) fn build_specs_with_discoverable_tools( min_timeout_ms: min_wait_timeout_ms, max_timeout_ms: MAX_WAIT_TIMEOUT_MS, }, - tool_search_entries: &tool_search_entries, }, - ); - let mut existing_spec_names = builder - .specs() - .iter() - .map(|configured_tool| configured_tool.name().to_string()) - .collect::>(); - - for unavailable_tool in unavailable_called_tools { - let tool_name = flat_tool_name(&unavailable_tool).into_owned(); - if existing_spec_names.insert(tool_name.clone()) { - let spec = codex_tools::ToolSpec::Function(ResponsesApiTool { - name: tool_name.clone(), - description: unavailable_tool_message( - &tool_name, - "Calling this placeholder returns an error explaining that the tool is unavailable.", - ), - strict: false, - parameters: JsonSchema::object( - Default::default(), - /*required*/ None, - Some(AdditionalProperties::Boolean(false)), - ), - output_schema: None, - defer_loading: None, - }); - builder.register_handler(Arc::new(UnavailableToolHandler::new( - unavailable_tool, - spec, - ))); - } else { - builder.register_handler(Arc::new(UnavailableToolHandler::without_spec( - unavailable_tool, - ))); - } - } - builder + ) } #[cfg(test)] diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 69456a36ef..3d37d58b1b 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -2,7 +2,6 @@ use crate::tools::code_mode::execute_spec::create_code_mode_tool; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; -use crate::tools::handlers::ContainerExecHandler; use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::ExecCommandHandler; @@ -10,7 +9,6 @@ use crate::tools::handlers::ExecCommandHandlerOptions; use crate::tools::handlers::GetGoalHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; -use crate::tools::handlers::LocalShellHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadMcpResourceHandler; @@ -19,7 +17,6 @@ use crate::tools::handlers::RequestPluginInstallHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellCommandHandlerOptions; -use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::ToolSearchHandler; use crate::tools::handlers::UpdateGoalHandler; @@ -39,107 +36,247 @@ use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHand use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; -use crate::tools::handlers::shell_spec::ShellToolOptions; use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::hosted_spec::WebSearchToolOptions; use crate::tools::hosted_spec::create_image_generation_tool; use crate::tools::hosted_spec::create_web_search_tool; +use crate::tools::registry::RegisteredTool; +use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistryBuilder; +use crate::tools::registry::override_tool_exposure; use crate::tools::spec_plan_types::ToolRegistryBuildParams; use crate::tools::spec_plan_types::agent_type_description; -use codex_mcp::ToolInfo; +use codex_extension_api::ExtensionToolExecutor; use codex_protocol::openai_models::ConfigShellToolType; -use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolEnvironmentMode; -use codex_tools::ToolName; -use codex_tools::ToolSearchSource; -use codex_tools::ToolSearchSourceInfo; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; -use codex_tools::coalesce_loadable_tool_specs; use codex_tools::collect_code_mode_exec_prompt_tool_definitions; -use codex_tools::collect_tool_search_source_infos; use codex_tools::default_namespace_description; -use codex_tools::dynamic_tool_to_loadable_tool_spec; -use codex_tools::mcp_tool_to_responses_api_tool; use std::collections::BTreeMap; -use std::collections::HashSet; use std::sync::Arc; pub fn build_tool_registry_builder( config: &ToolsConfig, params: ToolRegistryBuildParams<'_>, ) -> ToolRegistryBuilder { - let mut builder = ToolRegistryBuilder::new(config.code_mode_enabled); - let exec_permission_approvals_enabled = config.exec_permission_approvals_enabled; + let mut builder = ToolRegistryBuilder::new(); + let handlers = collect_handler_tools(config, params); + let deferred_tools_available = handlers + .iter() + .any(|handler| handler.exposure() == ToolExposure::Deferred); - if config.code_mode_enabled { - let namespace_descriptions = params - .tool_namespaces - .into_iter() - .flatten() - .map(|(namespace, detail)| { - ( - namespace.clone(), - codex_code_mode::ToolNamespaceDescription { - name: detail.name.clone(), - description: detail.description.clone().unwrap_or_default(), - }, - ) - }) - .collect::>(); - let nested_config = config.for_code_mode_nested_tools(); - let nested_builder = build_tool_registry_builder( - &nested_config, - ToolRegistryBuildParams { - discoverable_tools: None, - ..params - }, - ); - let mut enabled_tools = - collect_code_mode_exec_prompt_tool_definitions(nested_builder.specs().iter()); - enabled_tools - .sort_by(|left, right| compare_code_mode_tools(left, right, &namespace_descriptions)); - builder.register_handler(Arc::new(CodeModeExecuteHandler::new( + for handler in build_code_mode_handlers( + config, + &handlers, + params.extension_tool_executors, + config.search_tool && deferred_tools_available, + ) { + builder.register_tool(handler); + } + + let mut non_deferred_specs = Vec::new(); + let mut deferred_search_infos = Vec::new(); + for handler in &handlers { + match handler.exposure() { + ToolExposure::Direct | ToolExposure::DirectModelOnly => { + if let Some(spec) = handler.spec() { + non_deferred_specs.push((spec, handler.exposure())); + } + } + ToolExposure::Deferred => { + if let Some(search_info) = handler.search_info() { + deferred_search_infos.push(search_info); + } + } + } + } + + if let Some(web_search_tool) = create_web_search_tool(WebSearchToolOptions { + web_search_mode: config.web_search_mode, + web_search_config: config.web_search_config.as_ref(), + web_search_tool_type: config.web_search_tool_type, + }) { + non_deferred_specs.push((web_search_tool, ToolExposure::Direct)); + } + if config.image_gen_tool { + non_deferred_specs.push((create_image_generation_tool("png"), ToolExposure::Direct)); + } + + let non_deferred_specs = non_deferred_specs + .into_iter() + .map(|(spec, exposure)| { + if config.code_mode_enabled && exposure != ToolExposure::DirectModelOnly { + codex_tools::augment_tool_spec_for_code_mode(spec) + } else { + spec + } + }) + .collect(); + + for spec in merge_into_namespaces(non_deferred_specs) { + if !config.namespace_tools && matches!(spec, ToolSpec::Namespace(_)) { + continue; + } + builder.push_spec(spec); + } + + for handler in handlers { + builder.register_tool_without_spec(handler); + } + + if config.search_tool && config.namespace_tools && !deferred_search_infos.is_empty() { + builder.register_handler(Arc::new(ToolSearchHandler::new(deferred_search_infos))); + } + + for executor in params.extension_tool_executors.iter().cloned() { + builder.register_extension_tool_executor(executor); + } + + builder +} + +fn build_code_mode_handlers( + config: &ToolsConfig, + handlers: &[Arc], + extension_tool_executors: &[Arc], + deferred_tools_available: bool, +) -> Vec> { + if !config.code_mode_enabled { + return vec![]; + } + + let mut code_mode_nested_tool_specs = handlers + .iter() + .filter_map(|handler| { + if handler.exposure() == ToolExposure::DirectModelOnly { + return None; + } + + let spec = handler.spec()?; + Some(spec) + }) + .collect::>(); + code_mode_nested_tool_specs.extend( + extension_tool_executors + .iter() + .filter_map(|executor| executor.spec()), + ); + let namespace_descriptions = code_mode_namespace_descriptions(&code_mode_nested_tool_specs); + let mut enabled_tools = + collect_code_mode_exec_prompt_tool_definitions(code_mode_nested_tool_specs.iter()); + enabled_tools + .sort_by(|left, right| compare_code_mode_tools(left, right, &namespace_descriptions)); + + vec![ + Arc::new(CodeModeExecuteHandler::new( create_code_mode_tool( &enabled_tools, &namespace_descriptions, config.code_mode_only_enabled, - config.search_tool - && params - .deferred_mcp_tools - .is_some_and(|tools| !tools.is_empty()), + deferred_tools_available, ), - ))); - builder.register_handler(Arc::new(CodeModeWaitHandler)); + code_mode_nested_tool_specs, + )), + Arc::new(CodeModeWaitHandler), + ] +} + +fn merge_into_namespaces(specs: Vec) -> Vec { + let mut merged_specs = Vec::with_capacity(specs.len()); + let mut namespace_indices = BTreeMap::::new(); + for spec in specs { + match spec { + ToolSpec::Namespace(mut namespace) => { + if let Some(index) = namespace_indices.get(&namespace.name).copied() { + let ToolSpec::Namespace(existing_namespace) = &mut merged_specs[index] else { + unreachable!("namespace index must point to a namespace spec"); + }; + if existing_namespace.description.trim().is_empty() + && !namespace.description.trim().is_empty() + { + existing_namespace.description = namespace.description; + } + existing_namespace.tools.append(&mut namespace.tools); + continue; + } + + namespace_indices.insert(namespace.name.clone(), merged_specs.len()); + merged_specs.push(ToolSpec::Namespace(namespace)); + } + spec => merged_specs.push(spec), + } } + for spec in &mut merged_specs { + let ToolSpec::Namespace(namespace) = spec else { + continue; + }; + + namespace.tools.sort_by(|left, right| match (left, right) { + ( + ResponsesApiNamespaceTool::Function(left), + ResponsesApiNamespaceTool::Function(right), + ) => left.name.cmp(&right.name), + }); + + if namespace.description.trim().is_empty() { + namespace.description = default_namespace_description(&namespace.name); + } + } + + merged_specs +} + +fn code_mode_namespace_descriptions( + specs: &[ToolSpec], +) -> BTreeMap { + let mut namespace_descriptions = BTreeMap::new(); + for spec in specs { + let ToolSpec::Namespace(namespace) = spec else { + continue; + }; + + let entry = namespace_descriptions + .entry(namespace.name.clone()) + .or_insert_with(|| codex_code_mode::ToolNamespaceDescription { + name: namespace.name.clone(), + description: namespace.description.clone(), + }); + if entry.description.trim().is_empty() && !namespace.description.trim().is_empty() { + entry.description = namespace.description.clone(); + } + } + namespace_descriptions +} + +fn collect_handler_tools( + config: &ToolsConfig, + params: ToolRegistryBuildParams<'_>, +) -> Vec> { + let exec_permission_approvals_enabled = config.exec_permission_approvals_enabled; + let mut handlers = Vec::>::new(); + if config.environment_mode.has_environment() { let include_environment_id = matches!(config.environment_mode, ToolEnvironmentMode::Multiple); match &config.shell_type { - ConfigShellToolType::Default => { - builder.register_handler(Arc::new(ShellHandler::new(ShellToolOptions { - exec_permission_approvals_enabled, - }))); - } - ConfigShellToolType::Local => { - builder.register_handler(Arc::new(LocalShellHandler::new())); - } ConfigShellToolType::UnifiedExec => { - builder.register_handler(Arc::new(ExecCommandHandler::new( + handlers.push(Arc::new(ExecCommandHandler::new( ExecCommandHandlerOptions { allow_login_shell: config.allow_login_shell, exec_permission_approvals_enabled, include_environment_id, }, ))); - builder.register_handler(Arc::new(WriteStdinHandler)); + handlers.push(Arc::new(WriteStdinHandler)); } ConfigShellToolType::Disabled => {} - ConfigShellToolType::ShellCommand => { - builder.register_handler(Arc::new(ShellCommandHandler::new( + ConfigShellToolType::Default + | ConfigShellToolType::Local + | ConfigShellToolType::ShellCommand => { + handlers.push(Arc::new(ShellCommandHandler::new( ShellCommandHandlerOptions { backend_config: config.shell_command_backend, allow_login_shell: config.allow_login_shell, @@ -154,102 +291,44 @@ pub fn build_tool_registry_builder( && config.shell_type != ConfigShellToolType::Disabled { match &config.shell_type { - ConfigShellToolType::Default => { - builder.register_handler(Arc::new(ContainerExecHandler)); - builder.register_handler(Arc::new(LocalShellHandler::default())); - builder.register_handler(Arc::new(ShellCommandHandler::from( - config.shell_command_backend, - ))); - } - ConfigShellToolType::Local => { - builder.register_handler(Arc::new(ShellHandler::default())); - builder.register_handler(Arc::new(ContainerExecHandler)); - builder.register_handler(Arc::new(ShellCommandHandler::from( - config.shell_command_backend, - ))); - } ConfigShellToolType::UnifiedExec => { - builder.register_handler(Arc::new(ShellHandler::default())); - builder.register_handler(Arc::new(ContainerExecHandler)); - builder.register_handler(Arc::new(LocalShellHandler::default())); - builder.register_handler(Arc::new(ShellCommandHandler::from( + handlers.push(Arc::new(ShellCommandHandler::from( config.shell_command_backend, ))); } - ConfigShellToolType::ShellCommand => { - builder.register_handler(Arc::new(ShellHandler::default())); - builder.register_handler(Arc::new(ContainerExecHandler)); - builder.register_handler(Arc::new(LocalShellHandler::default())); - } - ConfigShellToolType::Disabled => {} + ConfigShellToolType::Default + | ConfigShellToolType::Local + | ConfigShellToolType::ShellCommand + | ConfigShellToolType::Disabled => {} } } if params.mcp_tools.is_some() { - builder.register_handler(Arc::new(ListMcpResourcesHandler)); - builder.register_handler(Arc::new(ListMcpResourceTemplatesHandler)); - builder.register_handler(Arc::new(ReadMcpResourceHandler)); + handlers.push(Arc::new(ListMcpResourcesHandler)); + handlers.push(Arc::new(ListMcpResourceTemplatesHandler)); + handlers.push(Arc::new(ReadMcpResourceHandler)); } - builder.register_handler(Arc::new(PlanHandler)); + handlers.push(Arc::new(PlanHandler)); if config.goal_tools { - builder.register_handler(Arc::new(GetGoalHandler)); - builder.register_handler(Arc::new(CreateGoalHandler)); - builder.register_handler(Arc::new(UpdateGoalHandler)); + handlers.push(Arc::new(GetGoalHandler)); + handlers.push(Arc::new(CreateGoalHandler)); + handlers.push(Arc::new(UpdateGoalHandler)); } - builder.register_handler(Arc::new(RequestUserInputHandler { + handlers.push(Arc::new(RequestUserInputHandler { available_modes: config.request_user_input_available_modes.clone(), })); if config.request_permissions_tool_enabled { - builder.register_handler(Arc::new(RequestPermissionsHandler)); - } - - let deferred_dynamic_tools = params - .dynamic_tools - .iter() - .filter(|tool| tool.defer_loading && (config.namespace_tools || tool.namespace.is_none())) - .collect::>(); - let deferred_mcp_tools_for_search = if config.namespace_tools { - params.deferred_mcp_tools - } else { - None - }; - - if config.search_tool - && (deferred_mcp_tools_for_search.is_some() || !deferred_dynamic_tools.is_empty()) - { - let mut search_source_infos = deferred_mcp_tools_for_search - .map(|deferred_mcp_tools| { - collect_tool_search_source_infos(deferred_mcp_tools.iter().map(|tool| { - ToolSearchSource { - server_name: tool.server_name.as_str(), - connector_name: tool.connector_name.as_deref(), - description: tool.namespace_description.as_deref(), - } - })) - }) - .unwrap_or_default(); - - if !deferred_dynamic_tools.is_empty() { - search_source_infos.push(ToolSearchSourceInfo { - name: "Dynamic tools".to_string(), - description: Some("Tools provided by the current Codex thread.".to_string()), - }); - } - - builder.register_handler(Arc::new(ToolSearchHandler::new( - params.tool_search_entries.to_vec(), - search_source_infos, - ))); + handlers.push(Arc::new(RequestPermissionsHandler)); } if config.tool_suggest && let Some(discoverable_tools) = params.discoverable_tools.filter(|tools| !tools.is_empty()) { - builder.register_handler(Arc::new(RequestPluginInstallHandler::new( + handlers.push(Arc::new(RequestPluginInstallHandler::new( discoverable_tools, ))); } @@ -257,7 +336,7 @@ pub fn build_tool_registry_builder( if config.environment_mode.has_environment() && config.apply_patch_tool_type.is_some() { let include_environment_id = matches!(config.environment_mode, ToolEnvironmentMode::Multiple); - builder.register_handler(Arc::new(ApplyPatchHandler::new(include_environment_id))); + handlers.push(Arc::new(ApplyPatchHandler::new(include_environment_id))); } if config @@ -265,25 +344,13 @@ pub fn build_tool_registry_builder( .iter() .any(|tool| tool == "test_sync_tool") { - builder.register_handler(Arc::new(TestSyncHandler)); - } - - if let Some(web_search_tool) = create_web_search_tool(WebSearchToolOptions { - web_search_mode: config.web_search_mode, - web_search_config: config.web_search_config.as_ref(), - web_search_tool_type: config.web_search_tool_type, - }) { - builder.push_spec(web_search_tool); - } - - if config.image_gen_tool { - builder.push_spec(create_image_generation_tool("png")); + handlers.push(Arc::new(TestSyncHandler)); } if config.environment_mode.has_environment() { let include_environment_id = matches!(config.environment_mode, ToolEnvironmentMode::Multiple); - builder.register_handler(Arc::new(ViewImageHandler::new(ViewImageToolOptions { + handlers.push(Arc::new(ViewImageHandler::new(ViewImageToolOptions { can_request_original_image_detail: config.can_request_original_image_detail, include_environment_id, }))); @@ -291,27 +358,36 @@ pub fn build_tool_registry_builder( if config.collab_tools { if config.multi_agent_v2 { + let exposure = if config.multi_agent_v2_non_code_mode_only { + ToolExposure::DirectModelOnly + } else { + ToolExposure::Direct + }; let agent_type_description = agent_type_description(config, params.default_agent_type_description); - builder.register_handler(Arc::new(SpawnAgentHandlerV2::new(SpawnAgentToolOptions { - available_models: config.available_models.clone(), - agent_type_description, - hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, - include_usage_hint: config.spawn_agent_usage_hint, - usage_hint_text: config.spawn_agent_usage_hint_text.clone(), - max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, - }))); - builder.register_handler(Arc::new(SendMessageHandlerV2)); - builder.register_handler(Arc::new(FollowupTaskHandlerV2)); - builder.register_handler(Arc::new(WaitAgentHandlerV2::new( - params.wait_agent_timeouts, - ))); - builder.register_handler(Arc::new(CloseAgentHandlerV2)); - builder.register_handler(Arc::new(ListAgentsHandlerV2)); + handlers.push(multi_agent_v2_handler( + SpawnAgentHandlerV2::new(SpawnAgentToolOptions { + available_models: config.available_models.clone(), + agent_type_description, + hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, + include_usage_hint: config.spawn_agent_usage_hint, + usage_hint_text: config.spawn_agent_usage_hint_text.clone(), + max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, + }), + exposure, + )); + handlers.push(multi_agent_v2_handler(SendMessageHandlerV2, exposure)); + handlers.push(multi_agent_v2_handler(FollowupTaskHandlerV2, exposure)); + handlers.push(multi_agent_v2_handler( + WaitAgentHandlerV2::new(params.wait_agent_timeouts), + exposure, + )); + handlers.push(multi_agent_v2_handler(CloseAgentHandlerV2, exposure)); + handlers.push(multi_agent_v2_handler(ListAgentsHandlerV2, exposure)); } else { let agent_type_description = agent_type_description(config, params.default_agent_type_description); - builder.register_handler(Arc::new(SpawnAgentHandler::new(SpawnAgentToolOptions { + handlers.push(Arc::new(SpawnAgentHandler::new(SpawnAgentToolOptions { available_models: config.available_models.clone(), agent_type_description, hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, @@ -319,122 +395,55 @@ pub fn build_tool_registry_builder( usage_hint_text: config.spawn_agent_usage_hint_text.clone(), max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, }))); - builder.register_handler(Arc::new(SendInputHandler)); - builder.register_handler(Arc::new(ResumeAgentHandler)); - builder.register_handler(Arc::new(WaitAgentHandler::new(params.wait_agent_timeouts))); - builder.register_handler(Arc::new(CloseAgentHandler)); + handlers.push(Arc::new(SendInputHandler)); + handlers.push(Arc::new(ResumeAgentHandler)); + handlers.push(Arc::new(WaitAgentHandler::new(params.wait_agent_timeouts))); + handlers.push(Arc::new(CloseAgentHandler)); } } if config.agent_jobs_tools { - builder.register_handler(Arc::new(SpawnAgentsOnCsvHandler)); + handlers.push(Arc::new(SpawnAgentsOnCsvHandler)); if config.agent_jobs_worker_tools { - builder.register_handler(Arc::new(ReportAgentJobResultHandler)); + handlers.push(Arc::new(ReportAgentJobResultHandler)); } } if let Some(mcp_tools) = params.mcp_tools { - let mut entries = mcp_tools - .iter() - .map(|tool| (tool.canonical_tool_name(), tool)) - .collect::>(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - let mut namespace_entries = BTreeMap::new(); - - for (tool_name, tool) in entries { - let Some(namespace) = tool_name.namespace.as_ref() else { - tracing::error!("Skipping MCP tool `{tool_name}`: MCP tools must be namespaced"); - continue; - }; - namespace_entries - .entry(namespace.clone()) - .or_insert_with(Vec::new) - .push((tool_name, tool)); - } - - for (namespace, mut entries) in namespace_entries { - entries.sort_by_key(|(tool_name, _)| tool_name.name.clone()); - let tool_namespace = params - .tool_namespaces - .and_then(|namespaces| namespaces.get(&namespace)); - let description = tool_namespace - .and_then(|namespace| namespace.description.as_deref()) - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| { - let namespace_name = tool_namespace - .map(|namespace| namespace.name.as_str()) - .unwrap_or(namespace.as_str()); - default_namespace_description(namespace_name) - }); - let mut tools = Vec::new(); - for (tool_name, tool) in entries { - match mcp_tool_to_responses_api_tool(&tool_name, &tool.tool) { - Ok(converted_tool) => { - tools.push(ResponsesApiNamespaceTool::Function(converted_tool)); - builder.register_handler(Arc::new(McpHandler::new(tool.clone()))); - } - Err(error) => { - tracing::error!( - "Failed to convert `{tool_name}` MCP tool to OpenAI tool: {error:?}" - ); - } - } - } - - if config.namespace_tools && !tools.is_empty() { - builder.push_spec(ToolSpec::Namespace(ResponsesApiNamespace { - name: namespace, - description, - tools, - })); - } - } - } - - let mut dynamic_tool_specs = Vec::new(); - for tool in params.dynamic_tools { - match dynamic_tool_to_loadable_tool_spec(tool) { - Ok(loadable_tool) => { - let handler_name = ToolName::new(tool.namespace.clone(), tool.name.clone()); - dynamic_tool_specs.push(loadable_tool); - builder.register_handler(Arc::new(DynamicToolHandler::new(handler_name))); - } - Err(error) => { - tracing::error!( - "Failed to convert dynamic tool {:?} to OpenAI tool: {error:?}", - tool.name - ); - } - } - } - for spec in coalesce_loadable_tool_specs(dynamic_tool_specs) { - let spec = spec.into(); - if config.namespace_tools || !matches!(spec, ToolSpec::Namespace(_)) { - builder.push_spec(spec); + for tool in mcp_tools { + handlers.push(Arc::new(McpHandler::new(tool.clone()))); } } if let Some(deferred_mcp_tools) = params.deferred_mcp_tools { - let directly_registered_mcp_tools = params - .mcp_tools - .into_iter() - .flatten() - .map(ToolInfo::canonical_tool_name) - .collect::>(); for tool in deferred_mcp_tools { - if !directly_registered_mcp_tools.contains(&tool.canonical_tool_name()) { - builder.register_handler(Arc::new(McpHandler::new(tool.clone()))); - } + handlers.push(Arc::new(McpHandler::with_exposure( + tool.clone(), + ToolExposure::Deferred, + ))); } } - for bundle in params.extension_tool_bundles.iter().cloned() { - builder.register_tool_bundle(bundle); + for tool in params.dynamic_tools { + let Some(handler) = DynamicToolHandler::new(tool).map(Arc::new) else { + tracing::error!( + "Failed to convert dynamic tool {:?} to OpenAI tool", + tool.name + ); + continue; + }; + + handlers.push(handler); } - builder + handlers +} + +fn multi_agent_v2_handler( + handler: impl RegisteredTool + 'static, + exposure: ToolExposure, +) -> Arc { + override_tool_exposure(Arc::new(handler), exposure) } fn compare_code_mode_tools( diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 2e1de3526b..c3fb316b1a 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -25,8 +25,9 @@ use crate::tools::handlers::shell_spec::request_permissions_tool_description; use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::handlers::view_image_spec::create_view_image_tool; use crate::tools::registry::ToolRegistry; -use crate::tools::spec_plan_types::ToolNamespace; use codex_app_server_protocol::AppInfo; +use codex_extension_api::ExtensionToolExecutor; +use codex_extension_api::ToolCall as ExtensionToolCall; use codex_features::Feature; use codex_features::Features; use codex_mcp::ToolInfo; @@ -42,10 +43,6 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_tool_api::FunctionToolSpec; -use codex_tool_api::ToolBundle as ExtensionToolBundle; -use codex_tool_api::ToolExecutor; -use codex_tool_api::ToolFuture; use codex_tools::AdditionalProperties; use codex_tools::DiscoverablePluginInfo; use codex_tools::DiscoverableTool; @@ -68,6 +65,7 @@ use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; use std::collections::HashMap; +use std::sync::Arc; const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const DEFAULT_AGENT_TYPE_DESCRIPTION: &str = "Test agent type description."; @@ -75,32 +73,44 @@ const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; const MAX_WAIT_TIMEOUT_MS: i64 = 3_600_000; -struct UnusedExtensionExecutor; - -impl ToolExecutor for UnusedExtensionExecutor { - fn execute<'a>(&'a self, _call: codex_tool_api::ToolCall) -> ToolFuture<'a> { - Box::pin(async { panic!("spec planning should not execute extension tools") }) +fn extension_tool_executor(name: &str, description: &str) -> Arc { + struct SpecOnlyExtensionExecutor { + name: String, + description: String, } -} -fn extension_tool_bundle(name: &str, description: &str) -> ExtensionToolBundle { - ExtensionToolBundle::new( - FunctionToolSpec { - name: name.to_string(), - description: description.to_string(), - strict: true, - parameters: serde_json::to_value(JsonSchema::object( - BTreeMap::from([( - "message".to_string(), - JsonSchema::string(/*description*/ None), - )]), - Some(vec!["message".to_string()]), - Some(false.into()), - )) - .expect("extension schema should serialize"), - }, - std::sync::Arc::new(UnusedExtensionExecutor), - ) + impl ExtensionToolExecutor for SpecOnlyExtensionExecutor { + fn tool_name(&self) -> ToolName { + ToolName::plain(self.name.as_str()) + } + + fn spec(&self) -> Option { + Some(ToolSpec::Function(ResponsesApiTool { + name: self.name.clone(), + description: self.description.clone(), + strict: true, + parameters: JsonSchema::object( + BTreeMap::from([( + "message".to_string(), + JsonSchema::string(/*description*/ None), + )]), + Some(vec!["message".to_string()]), + Some(false.into()), + ), + output_schema: None, + defer_loading: None, + })) + } + + fn handle(&self, _call: ExtensionToolCall) -> codex_extension_api::ExtensionToolFuture<'_> { + Box::pin(async { panic!("spec planning should not execute extension tools") }) + } + } + + Arc::new(SpecOnlyExtensionExecutor { + name: name.to_string(), + description: description.to_string(), + }) } #[test] @@ -117,7 +127,7 @@ fn extension_tools_do_not_replace_builtin_tools() { permission_profile: &PermissionProfile::Disabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let extension_tool_bundles = vec![extension_tool_bundle( + let extension_tool_executors = vec![extension_tool_executor( "update_plan", "Extension attempt to replace a built-in tool.", )]; @@ -126,7 +136,7 @@ fn extension_tools_do_not_replace_builtin_tools() { /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, /*discoverable_tools*/ None, - &extension_tool_bundles, + &extension_tool_executors, &[], ); @@ -1579,20 +1589,7 @@ fn search_tool_description_lists_each_mcp_source_once() { let (tools, registry) = build_specs( &tools_config, - Some(HashMap::from([ - ( - ToolName::namespaced("mcp__codex_apps__calendar", "_create_event"), - mcp_tool( - "calendar_create_event", - "Create calendar event", - serde_json::json!({"type": "object"}), - ), - ), - ( - ToolName::namespaced("mcp__rmcp__", "echo"), - mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), - ), - ])), + /*mcp_tools*/ None, Some(vec![ deferred_mcp_tool( "_create_event", @@ -1723,7 +1720,7 @@ fn search_tool_requires_model_capability_and_enabled_feature() { } #[test] -fn search_tool_is_hidden_when_only_deferred_namespace_tools_are_available() { +fn no_search_tool_when_namespaces_disabled() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::ToolSearch); @@ -1810,32 +1807,15 @@ fn search_tool_registers_for_deferred_dynamic_tools() { panic!("expected tool_search tool"); }; assert!(description.contains("- Dynamic tools: Tools provided by the current Codex thread.")); - assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME, "codex_app"]); - assert_eq!( - tools - .iter() - .filter(|tool| tool.name() == "codex_app") - .count(), - 1 - ); - assert_eq!( - namespace_function_names(&tools, "codex_app"), - vec![ - "automation_update".to_string(), - "automation_list".to_string() - ] - ); - for tool_name in ["automation_update", "automation_list"] { - let dynamic_tool = find_namespace_function_tool(&tools, "codex_app", tool_name); - assert_eq!(dynamic_tool.defer_loading, Some(true)); - } + assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]); + assert_lacks_tool_name(&tools, "codex_app"); assert!(registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); assert!(registry.has_handler(&ToolName::namespaced("codex_app", "automation_update"))); assert!(registry.has_handler(&ToolName::namespaced("codex_app", "automation_list"))); } #[test] -fn search_tool_keeps_plain_deferred_dynamic_tools_when_namespace_tools_are_disabled() { +fn search_tool_is_hidden_for_deferred_dynamic_tools_when_namespace_tools_are_disabled() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::ToolSearch); @@ -1875,9 +1855,12 @@ fn search_tool_keeps_plain_deferred_dynamic_tools_when_namespace_tools_are_disab &dynamic_tools, ); - assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME, "plain_dynamic"]); + assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); assert_lacks_tool_name(&tools, "codex_app"); - assert!(registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); + assert_lacks_tool_name(&tools, "plain_dynamic"); + assert!(!registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); + assert!(registry.has_handler(&ToolName::namespaced("codex_app", "automation_update"))); + assert!(registry.has_handler(&ToolName::plain("plain_dynamic"))); } #[test] @@ -1908,7 +1891,7 @@ fn request_plugin_install_is_not_registered_without_feature_flag() { "Google Calendar", "Plan events and schedules.", )]), - /*extension_tool_bundles*/ &[], + /*extension_tool_executors*/ &[], &[], ); @@ -1949,7 +1932,7 @@ fn request_plugin_install_can_be_registered_without_search_tool() { "Google Calendar", "Plan events and schedules.", )]), - /*extension_tool_bundles*/ &[], + /*extension_tool_executors*/ &[], &[], ); @@ -2014,7 +1997,7 @@ fn request_plugin_install_description_lists_discoverable_tools() { /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, Some(discoverable_tools), - /*extension_tool_bundles*/ &[], + /*extension_tool_executors*/ &[], &[], ); assert!(registry.has_handler(&ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME))); @@ -2307,7 +2290,7 @@ fn code_mode_only_exec_description_includes_extension_tool_details() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let extension_tool_bundles = vec![extension_tool_bundle( + let extension_tool_executors = vec![extension_tool_executor( "extension_echo", "Echoes arguments through an extension tool.", )]; @@ -2316,7 +2299,7 @@ fn code_mode_only_exec_description_includes_extension_tool_details() { /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, /*discoverable_tools*/ None, - &extension_tool_bundles, + &extension_tool_executors, &[], ); let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { @@ -2414,7 +2397,7 @@ fn build_specs( mcp_tools, deferred_mcp_tools, /*discoverable_tools*/ None, - /*extension_tool_bundles*/ &[], + /*extension_tool_executors*/ &[], dynamic_tools, ) } @@ -2424,27 +2407,7 @@ fn build_specs_with_discoverable_tools( mcp_tools: Option>, deferred_mcp_tools: Option>, discoverable_tools: Option>, - extension_tool_bundles: &[codex_tool_api::ToolBundle], - dynamic_tools: &[DynamicToolSpec], -) -> (Vec, ToolRegistry) { - build_specs_with_optional_tool_namespaces( - config, - mcp_tools, - deferred_mcp_tools, - /*tool_namespaces*/ None, - discoverable_tools, - extension_tool_bundles, - dynamic_tools, - ) -} - -fn build_specs_with_optional_tool_namespaces( - config: &ToolsConfig, - mcp_tools: Option>, - deferred_mcp_tools: Option>, - tool_namespaces: Option>, - discoverable_tools: Option>, - extension_tool_bundles: &[codex_tool_api::ToolBundle], + extension_tool_executors: &[Arc], dynamic_tools: &[DynamicToolSpec], ) -> (Vec, ToolRegistry) { let mcp_tool_inputs = mcp_tools.as_ref().map(|mcp_tools| { @@ -2458,13 +2421,11 @@ fn build_specs_with_optional_tool_namespaces( ToolRegistryBuildParams { mcp_tools: mcp_tool_inputs.as_deref(), deferred_mcp_tools: deferred_mcp_tools.as_deref(), - tool_namespaces: tool_namespaces.as_ref(), discoverable_tools: discoverable_tools.as_deref(), - extension_tool_bundles, + extension_tool_executors, dynamic_tools, default_agent_type_description: DEFAULT_AGENT_TYPE_DESCRIPTION, wait_agent_timeouts: wait_agent_timeout_options(), - tool_search_entries: &[], }, ); builder.build() @@ -2811,7 +2772,6 @@ fn strip_descriptions_tool(spec: &mut ToolSpec) { } } ToolSpec::Freeform(FreeformTool { .. }) - | ToolSpec::LocalShell {} | ToolSpec::ImageGeneration { .. } | ToolSpec::WebSearch { .. } => {} } diff --git a/codex-rs/core/src/tools/spec_plan_types.rs b/codex-rs/core/src/tools/spec_plan_types.rs index 9e9ff555ce..247fa05715 100644 --- a/codex-rs/core/src/tools/spec_plan_types.rs +++ b/codex-rs/core/src/tools/spec_plan_types.rs @@ -1,28 +1,20 @@ use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; +use codex_extension_api::ExtensionToolExecutor; use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_tool_api::ToolBundle as ExtensionToolBundle; use codex_tools::DiscoverableTool; use codex_tools::ToolsConfig; -use std::collections::HashMap; +use std::sync::Arc; #[derive(Clone, Copy)] pub struct ToolRegistryBuildParams<'a> { pub mcp_tools: Option<&'a [ToolInfo]>, pub deferred_mcp_tools: Option<&'a [ToolInfo]>, - pub tool_namespaces: Option<&'a HashMap>, pub discoverable_tools: Option<&'a [DiscoverableTool]>, - pub extension_tool_bundles: &'a [ExtensionToolBundle], + pub extension_tool_executors: &'a [Arc], pub dynamic_tools: &'a [DynamicToolSpec], pub default_agent_type_description: &'a str, pub wait_agent_timeouts: WaitAgentTimeoutOptions, - pub tool_search_entries: &'a [crate::tools::tool_search_entry::ToolSearchEntry], -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ToolNamespace { - pub name: String, - pub description: Option, } pub(crate) fn agent_type_description( diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 4a99d249b4..0bd53184c8 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -16,10 +16,8 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::SessionSource; -use codex_tools::AdditionalProperties; use codex_tools::DiscoverableTool; use codex_tools::JsonSchema; -use codex_tools::LoadableToolSpec; use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; @@ -40,7 +38,6 @@ use std::collections::BTreeMap; use std::path::PathBuf; use super::*; -use crate::tools::tool_search_entry::build_tool_search_entries_for_config; fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { rmcp::model::Tool { @@ -187,8 +184,8 @@ fn assert_contains_tool_names(tools: &[ToolSpec], expected_subset: &[&str]) { fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> { match config.shell_type { - ConfigShellToolType::Default => Some("shell"), - ConfigShellToolType::Local => Some("local_shell"), + ConfigShellToolType::Default => Some("shell_command"), + ConfigShellToolType::Local => Some("shell_command"), ConfigShellToolType::UnifiedExec => None, ConfigShellToolType::Disabled => None, ConfigShellToolType::ShellCommand => Some("shell_command"), @@ -274,30 +271,13 @@ fn build_specs( mcp_tools: Option>, deferred_mcp_tools: Option>, dynamic_tools: &[DynamicToolSpec], -) -> ToolRegistryBuilder { - build_specs_with_unavailable_tools( - config, - mcp_tools, - deferred_mcp_tools, - Vec::new(), - dynamic_tools, - ) -} - -fn build_specs_with_unavailable_tools( - config: &ToolsConfig, - mcp_tools: Option>, - deferred_mcp_tools: Option>, - unavailable_called_tools: Vec, - dynamic_tools: &[DynamicToolSpec], ) -> ToolRegistryBuilder { build_specs_with_discoverable_tools( config, mcp_tools, deferred_mcp_tools, - unavailable_called_tools, /*discoverable_tools*/ None, - /*extension_tool_bundles*/ &[], + /*extension_tool_executors*/ &[], dynamic_tools, ) } @@ -356,9 +336,8 @@ async fn assert_model_tools( ToolRouterParams { mcp_tools: None, deferred_mcp_tools: None, - unavailable_called_tools: Vec::new(), discoverable_tools: None, - extension_tool_bundles: Vec::new(), + extension_tool_executors: Vec::new(), dynamic_tools: &[], }, ); @@ -367,7 +346,7 @@ async fn assert_model_tools( .iter() .map(ToolSpec::name) .collect::>(); - assert_eq!(&tool_names, &expected_tools,); + assert_eq!(&tool_names, &expected_tools); } async fn assert_default_model_tools( @@ -398,14 +377,14 @@ async fn test_build_specs_gpt5_codex_default() { "update_plan", "request_user_input", "apply_patch", - "web_search", - "image_generation", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", + "web_search", + "image_generation", ], ) .await; @@ -423,14 +402,14 @@ async fn test_build_specs_gpt51_codex_default() { "update_plan", "request_user_input", "apply_patch", - "web_search", - "image_generation", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", + "web_search", + "image_generation", ], ) .await; @@ -450,14 +429,14 @@ async fn test_build_specs_gpt5_codex_unified_exec_web_search() { "update_plan", "request_user_input", "apply_patch", - "web_search", - "image_generation", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", + "web_search", + "image_generation", ], ) .await; @@ -477,14 +456,14 @@ async fn test_build_specs_gpt51_codex_unified_exec_web_search() { "update_plan", "request_user_input", "apply_patch", - "web_search", - "image_generation", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", + "web_search", + "image_generation", ], ) .await; @@ -502,14 +481,14 @@ async fn test_gpt_5_1_codex_max_defaults() { "update_plan", "request_user_input", "apply_patch", - "web_search", - "image_generation", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", + "web_search", + "image_generation", ], ) .await; @@ -527,14 +506,14 @@ async fn test_codex_5_1_mini_defaults() { "update_plan", "request_user_input", "apply_patch", - "web_search", - "image_generation", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", + "web_search", + "image_generation", ], ) .await; @@ -552,14 +531,14 @@ async fn test_gpt_5_defaults() { "update_plan", "request_user_input", "apply_patch", - "web_search", - "image_generation", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", + "web_search", + "image_generation", ], ) .await; @@ -577,14 +556,14 @@ async fn test_gpt_5_1_defaults() { "update_plan", "request_user_input", "apply_patch", - "web_search", - "image_generation", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", + "web_search", + "image_generation", ], ) .await; @@ -604,14 +583,14 @@ async fn test_gpt_5_1_codex_max_unified_exec_web_search() { "update_plan", "request_user_input", "apply_patch", - "web_search", - "image_generation", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", + "web_search", + "image_generation", ], ) .await; @@ -826,9 +805,8 @@ async fn request_plugin_install_requires_apps_and_plugins_features() { &tools_config, /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, - Vec::new(), discoverable_tools.clone(), - /*extension_tool_bundles*/ &[], + /*extension_tool_executors*/ &[], &[], ) .build(); @@ -843,7 +821,7 @@ async fn request_plugin_install_requires_apps_and_plugins_features() { } #[tokio::test] -async fn search_tool_description_handles_no_enabled_mcp_tools() { +async fn search_tool_is_hidden_without_deferred_tools() { let model_info = search_capable_model_info().await; let mut features = Features::with_defaults(); features.enable(Feature::Apps); @@ -867,13 +845,11 @@ async fn search_tool_description_handles_no_enabled_mcp_tools() { &[], ) .build(); - let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); - let ToolSpec::ToolSearch { description, .. } = search_tool else { - panic!("expected tool_search tool"); - }; - - assert!(description.contains("None currently enabled.")); - assert!(!description.contains("{{source_descriptions}}")); + assert!( + !tools + .iter() + .any(|tool| tool.name() == TOOL_SEARCH_TOOL_NAME) + ); } #[tokio::test] @@ -1008,59 +984,6 @@ async fn search_tool_registers_namespaced_mcp_tool_aliases() { assert!(registry.has_handler(&mcp_alias)); } -#[tokio::test] -async fn tool_search_entries_skip_namespace_outputs_when_namespace_tools_are_disabled() { - let model_info = search_capable_model_info().await; - let mut features = Features::with_defaults(); - features.enable(Feature::ToolSearch); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.namespace_tools = false; - let mcp_tools = vec![mcp_tool_info(mcp_tool( - "echo", - "Echo", - serde_json::json!({"type": "object"}), - ))]; - let dynamic_tools = vec![ - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "automation_update".to_string(), - description: "Create or update automations.".to_string(), - input_schema: serde_json::json!({"type": "object", "properties": {}}), - defer_loading: true, - }, - DynamicToolSpec { - namespace: None, - name: "plain_dynamic".to_string(), - description: "Plain dynamic tool.".to_string(), - input_schema: serde_json::json!({"type": "object", "properties": {}}), - defer_loading: true, - }, - ]; - - let entries = - build_tool_search_entries_for_config(&tools_config, Some(&mcp_tools), &dynamic_tools); - let outputs = entries - .into_iter() - .map(|entry| entry.output) - .collect::>(); - - assert_eq!(outputs.len(), 1); - match &outputs[0] { - LoadableToolSpec::Function(tool) => assert_eq!(tool.name, "plain_dynamic"), - LoadableToolSpec::Namespace(_) => panic!("namespace tool_search output should be hidden"), - } -} - #[tokio::test] async fn direct_mcp_tools_register_namespaced_handlers() { let config = test_config().await; @@ -1095,55 +1018,6 @@ async fn direct_mcp_tools_register_namespaced_handlers() { assert!(!registry.has_handler(&ToolName::plain("mcp__test_server__echo"))); } -#[tokio::test] -async fn unavailable_mcp_tools_are_exposed_as_dummy_function_tools() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let unavailable_tool = ToolName::namespaced("mcp__codex_apps__calendar", "_create_event"); - let (tools, registry) = build_specs_with_unavailable_tools( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - vec![unavailable_tool], - &[], - ) - .build(); - - let tool = find_tool(&tools, "mcp__codex_apps__calendar_create_event"); - let ToolSpec::Function(ResponsesApiTool { - description, - parameters, - .. - }) = tool - else { - panic!("unavailable MCP tool should be exposed as a function tool"); - }; - assert!(description.contains("not currently available")); - assert_eq!( - parameters.additional_properties, - Some(AdditionalProperties::Boolean(false)) - ); - assert!(registry.has_handler(&ToolName::namespaced( - "mcp__codex_apps__calendar", - "_create_event" - ))); - assert!(!registry.has_handler(&ToolName::plain("mcp__codex_apps__calendar_create_event"))); -} - #[tokio::test] async fn test_mcp_tool_property_missing_type_defaults_to_string() { let config = test_config().await; @@ -1516,3 +1390,67 @@ async fn code_mode_only_restricts_model_tools_to_exec_tools() { ) .await; } + +#[tokio::test] +async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() { + let config = test_config().await; + let model_info = construct_model_info_offline("gpt-5.4", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + features.enable(Feature::CodeModeOnly); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true); + let router = ToolRouter::from_config( + &tools_config, + ToolRouterParams { + mcp_tools: None, + deferred_mcp_tools: None, + discoverable_tools: None, + extension_tool_executors: Vec::new(), + dynamic_tools: &[], + }, + ); + let model_visible_specs = router.model_visible_specs(); + let tool_names = model_visible_specs + .iter() + .map(ToolSpec::name) + .collect::>(); + + assert_eq!( + tool_names, + vec![ + "exec", + "wait", + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ] + ); + + let exec = find_tool(&model_visible_specs, "exec"); + let ToolSpec::Freeform(exec) = exec else { + panic!("exec should be a freeform tool"); + }; + assert!(!exec.description.contains("spawn_agent")); + assert!(!exec.description.contains("wait_agent")); + + let spawn_agent = find_tool(&model_visible_specs, "spawn_agent"); + let ToolSpec::Function(spawn_agent) = spawn_agent else { + panic!("spawn_agent should be a function tool"); + }; + assert!(!spawn_agent.description.contains("exec tool declaration")); +} diff --git a/codex-rs/core/src/tools/tool_dispatch_trace.rs b/codex-rs/core/src/tools/tool_dispatch_trace.rs index 6a0e635792..522e3cd1d8 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace.rs @@ -111,15 +111,6 @@ fn tool_dispatch_payload(payload: &ToolPayload) -> ToolDispatchPayload { ToolPayload::Custom { input } => ToolDispatchPayload::Custom { input: input.clone(), }, - ToolPayload::LocalShell { params } => ToolDispatchPayload::LocalShell { - command: params.command.clone(), - workdir: params.workdir.clone(), - timeout_ms: params.timeout_ms, - sandbox_permissions: params.sandbox_permissions, - prefix_rule: params.prefix_rule.clone(), - additional_permissions: params.additional_permissions.clone(), - justification: params.justification.clone(), - }, } } diff --git a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs index f0c808b30b..3badf1155a 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs @@ -21,6 +21,7 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolRegistry; use crate::turn_diff_tracker::TurnDiffTracker; @@ -29,7 +30,7 @@ struct TestHandler { tool_name: codex_tools::ToolName, } -impl ToolHandler for TestHandler { +impl ToolExecutor for TestHandler { type Output = FunctionToolOutput; fn tool_name(&self) -> codex_tools::ToolName { @@ -41,6 +42,8 @@ impl ToolHandler for TestHandler { } } +impl ToolHandler for TestHandler {} + #[tokio::test] async fn dispatch_lifecycle_trace_records_direct_and_code_mode_requesters() -> anyhow::Result<()> { let temp = TempDir::new()?; diff --git a/codex-rs/core/src/tools/tool_search_entry.rs b/codex-rs/core/src/tools/tool_search_entry.rs index 6a8ccd6402..8e37cdcfff 100644 --- a/codex-rs/core/src/tools/tool_search_entry.rs +++ b/codex-rs/core/src/tools/tool_search_entry.rs @@ -1,171 +1,56 @@ -use crate::tools::flat_tool_name; -use codex_mcp::ToolInfo; -use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_tools::LoadableToolSpec; -use codex_tools::ToolSearchResultSource; -use codex_tools::ToolsConfig; -use codex_tools::dynamic_tool_to_loadable_tool_spec; -use codex_tools::tool_search_result_source_to_loadable_tool_spec; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ToolSearchSourceInfo; +use codex_tools::ToolSpec; +use codex_tools::default_namespace_description; #[derive(Clone)] pub(crate) struct ToolSearchEntry { pub(crate) search_text: String, pub(crate) output: LoadableToolSpec, - pub(crate) limit_bucket: Option, } -pub(crate) fn build_tool_search_entries( - mcp_tools: Option<&[ToolInfo]>, - dynamic_tools: &[DynamicToolSpec], -) -> Vec { - let mut entries = Vec::new(); +#[derive(Clone)] +pub(crate) struct ToolSearchInfo { + pub(crate) entry: ToolSearchEntry, + pub(crate) source_info: Option, +} - let mut mcp_tools = mcp_tools - .map(|tools| tools.iter().collect::>()) - .unwrap_or_default(); - mcp_tools.sort_by_key(|info| info.canonical_tool_name()); - for info in mcp_tools { - match mcp_tool_search_entry(info) { - Ok(entry) => entries.push(entry), - Err(error) => { - let tool_name = info.canonical_tool_name(); - tracing::error!( - "Failed to convert deferred MCP tool `{tool_name}` to OpenAI tool: {error:?}" - ); +impl ToolSearchInfo { + pub(crate) fn from_spec( + search_text: String, + spec: ToolSpec, + source_info: Option, + ) -> Option { + let output = match spec { + ToolSpec::Function(mut tool) => { + tool.defer_loading = Some(true); + tool.output_schema = None; + LoadableToolSpec::Function(tool) } - } - } - - let mut dynamic_tools = dynamic_tools.iter().collect::>(); - dynamic_tools.sort_by(|a, b| a.namespace.cmp(&b.namespace).then(a.name.cmp(&b.name))); - for tool in dynamic_tools { - match dynamic_tool_search_entry(tool) { - Ok(entry) => entries.push(entry), - Err(error) => { - tracing::error!( - "Failed to convert deferred dynamic tool {:?} to OpenAI tool: {error:?}", - tool.name - ); + ToolSpec::Namespace(mut namespace) => { + if namespace.description.trim().is_empty() { + namespace.description = default_namespace_description(&namespace.name); + } + for tool in &mut namespace.tools { + let ResponsesApiNamespaceTool::Function(tool) = tool; + tool.defer_loading = Some(true); + tool.output_schema = None; + } + LoadableToolSpec::Namespace(namespace) } - } + ToolSpec::ToolSearch { .. } + | ToolSpec::ImageGeneration { .. } + | ToolSpec::WebSearch { .. } + | ToolSpec::Freeform(_) => return None, + }; + + Some(Self { + entry: ToolSearchEntry { + search_text, + output, + }, + source_info, + }) } - - entries -} - -pub(crate) fn build_tool_search_entries_for_config( - config: &ToolsConfig, - mcp_tools: Option<&[ToolInfo]>, - dynamic_tools: &[DynamicToolSpec], -) -> Vec { - let mcp_tools = if config.namespace_tools { - mcp_tools - } else { - None - }; - let dynamic_tools = dynamic_tools - .iter() - .filter(|tool| config.namespace_tools || tool.namespace.is_none()) - .cloned() - .collect::>(); - build_tool_search_entries(mcp_tools, &dynamic_tools) -} - -fn mcp_tool_search_entry(info: &ToolInfo) -> Result { - Ok(ToolSearchEntry { - search_text: build_mcp_search_text(info), - output: tool_search_result_source_to_loadable_tool_spec(ToolSearchResultSource { - server_name: info.server_name.as_str(), - tool_namespace: info.callable_namespace.as_str(), - tool_name: info.callable_name.as_str(), - tool: &info.tool, - connector_name: info.connector_name.as_deref(), - description: info.namespace_description.as_deref(), - })?, - limit_bucket: Some(info.server_name.clone()), - }) -} - -fn dynamic_tool_search_entry(tool: &DynamicToolSpec) -> Result { - Ok(ToolSearchEntry { - search_text: build_dynamic_search_text(tool), - output: dynamic_tool_to_loadable_tool_spec(tool)?, - limit_bucket: None, - }) -} - -fn build_mcp_search_text(info: &ToolInfo) -> String { - let tool_name = info.canonical_tool_name(); - let mut parts = vec![ - flat_tool_name(&tool_name).into_owned(), - info.callable_name.clone(), - info.tool.name.to_string(), - info.server_name.clone(), - ]; - - if let Some(title) = info.tool.title.as_deref() - && !title.trim().is_empty() - { - parts.push(title.to_string()); - } - - if let Some(description) = info.tool.description.as_deref() - && !description.trim().is_empty() - { - parts.push(description.to_string()); - } - - if let Some(connector_name) = info.connector_name.as_deref() - && !connector_name.trim().is_empty() - { - parts.push(connector_name.to_string()); - } - - if let Some(description) = info.namespace_description.as_deref() - && !description.trim().is_empty() - { - parts.push(description.to_string()); - } - - parts.extend( - info.plugin_display_names - .iter() - .map(String::as_str) - .map(str::trim) - .filter(|name| !name.is_empty()) - .map(str::to_string), - ); - - parts.extend( - info.tool - .input_schema - .get("properties") - .and_then(serde_json::Value::as_object) - .map(|map| map.keys().cloned().collect::>()) - .unwrap_or_default(), - ); - - parts.join(" ") -} - -fn build_dynamic_search_text(tool: &DynamicToolSpec) -> String { - let mut parts = vec![ - tool.name.clone(), - tool.name.replace('_', " "), - tool.description.clone(), - ]; - - if let Some(namespace) = &tool.namespace { - parts.push(namespace.clone()); - } - - parts.extend( - tool.input_schema - .get("properties") - .and_then(serde_json::Value::as_object) - .map(|map| map.keys().cloned().collect::>()) - .unwrap_or_default(), - ); - - parts.join(" ") } diff --git a/codex-rs/core/src/unavailable_tool.rs b/codex-rs/core/src/unavailable_tool.rs deleted file mode 100644 index 9f71facead..0000000000 --- a/codex-rs/core/src/unavailable_tool.rs +++ /dev/null @@ -1,113 +0,0 @@ -use std::collections::BTreeMap; -use std::collections::HashSet; - -use crate::tools::flat_tool_name; -use codex_protocol::models::ResponseItem; -use codex_tools::ToolName; - -pub(crate) fn collect_unavailable_called_tools( - input: &[ResponseItem], - exposed_tool_names: &HashSet, -) -> Vec { - let mut unavailable_tools = BTreeMap::new(); - let exposed_flat_names = exposed_tool_names - .iter() - .map(flat_tool_name) - .collect::>(); - - for item in input { - let ResponseItem::FunctionCall { - name, namespace, .. - } = item - else { - continue; - }; - if !should_collect_unavailable_tool(name, namespace.as_deref()) { - continue; - } - - let tool_name = match namespace { - Some(namespace) => ToolName::namespaced(namespace.clone(), name.clone()), - None => ToolName::plain(name.clone()), - }; - let tool_name_flat = flat_tool_name(&tool_name).into_owned(); - if exposed_flat_names.contains(tool_name_flat.as_str()) { - continue; - } - - unavailable_tools - .entry(tool_name_flat) - .or_insert_with(|| tool_name); - } - - unavailable_tools.into_values().collect() -} - -fn should_collect_unavailable_tool(name: &str, namespace: Option<&str>) -> bool { - namespace.is_some_and(|namespace| namespace.starts_with("mcp__")) || name.starts_with("mcp__") -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - fn function_call(name: &str, namespace: Option<&str>) -> ResponseItem { - ResponseItem::FunctionCall { - id: None, - name: name.to_string(), - namespace: namespace.map(str::to_string), - arguments: "{}".to_string(), - call_id: format!("call-{name}"), - } - } - - #[test] - fn collect_unavailable_called_tools_detects_mcp_function_calls() { - let input = vec![ - function_call("shell", /*namespace*/ None), - function_call("mcp__server__lookup", /*namespace*/ None), - function_call("_create_event", Some("mcp__codex_apps__calendar")), - ]; - - let tools = collect_unavailable_called_tools(&input, &HashSet::new()); - - assert_eq!( - tools, - vec![ - ToolName::namespaced("mcp__codex_apps__calendar", "_create_event"), - ToolName::plain("mcp__server__lookup"), - ] - ); - } - - #[test] - fn collect_unavailable_called_tools_skips_currently_available_tools() { - let exposed_tool_names = HashSet::from([ - ToolName::plain("mcp__server__lookup"), - ToolName::plain("mcp__server__search"), - ]); - let input = vec![ - function_call("mcp__server__lookup", /*namespace*/ None), - function_call("mcp__server__search", /*namespace*/ None), - function_call("mcp__server__missing", /*namespace*/ None), - ]; - - let tools = collect_unavailable_called_tools(&input, &exposed_tool_names); - - assert_eq!(tools, vec![ToolName::plain("mcp__server__missing")]); - } - - #[test] - fn collect_unavailable_called_tools_matches_exposed_display_names() { - let exposed_tool_names = HashSet::from([ToolName::namespaced("mcp__server__", "lookup")]); - let input = vec![function_call( - "mcp__server__lookup", - /*namespace*/ None, - )]; - - let tools = collect_unavailable_called_tools(&input, &exposed_tool_names); - - assert_eq!(tools, Vec::new()); - } -} diff --git a/codex-rs/core/tests/cli_responses_fixture.sse b/codex-rs/core/tests/cli_responses_fixture.sse deleted file mode 100644 index d297ebafb2..0000000000 --- a/codex-rs/core/tests/cli_responses_fixture.sse +++ /dev/null @@ -1,8 +0,0 @@ -event: response.created -data: {"type":"response.created","response":{"id":"resp1"}} - -event: response.output_item.done -data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"fixture hello"}]}} - -event: response.completed -data: {"type":"response.completed","response":{"id":"resp1","output":[]}} diff --git a/codex-rs/core/tests/common/BUILD.bazel b/codex-rs/core/tests/common/BUILD.bazel index 983a2012b0..f41a727b72 100644 --- a/codex-rs/core/tests/common/BUILD.bazel +++ b/codex-rs/core/tests/common/BUILD.bazel @@ -4,9 +4,6 @@ codex_rust_crate( name = "common", crate_name = "core_test_support", crate_srcs = glob(["*.rs"]), - lib_data_extra = [ - "//codex-rs/core:model_availability_nux_fixtures", - ], deps_extra = [ "@crates//:similar", ], diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index eab90c5aba..6c1f581f9f 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -890,10 +890,6 @@ pub fn ev_apply_patch_call( ) -> Value { match output_type { ApplyPatchModelOutput::Freeform => ev_apply_patch_custom_tool_call(call_id, patch), - ApplyPatchModelOutput::Shell => ev_apply_patch_shell_call(call_id, patch), - ApplyPatchModelOutput::ShellViaHeredoc => { - ev_apply_patch_shell_call_via_heredoc(call_id, patch) - } ApplyPatchModelOutput::ShellCommandViaHeredoc => { ev_apply_patch_shell_command_call_via_heredoc(call_id, patch) } @@ -925,21 +921,6 @@ pub fn ev_shell_command_call_with_args(call_id: &str, args: &serde_json::Value) ev_function_call(call_id, "shell_command", &arguments) } -pub fn ev_apply_patch_shell_call(call_id: &str, patch: &str) -> Value { - let args = serde_json::json!({ "command": ["apply_patch", patch] }); - let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments"); - - ev_function_call(call_id, "shell", &arguments) -} - -pub fn ev_apply_patch_shell_call_via_heredoc(call_id: &str, patch: &str) -> Value { - let script = format!("apply_patch <<'EOF'\n{patch}\nEOF\n"); - let args = serde_json::json!({ "command": ["bash", "-lc", script] }); - let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments"); - - ev_function_call(call_id, "shell", &arguments) -} - pub fn ev_apply_patch_shell_command_call_via_heredoc(call_id: &str, patch: &str) -> Value { let args = serde_json::json!({ "command": format!("apply_patch <<'EOF'\n{patch}\nEOF\n") }); let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments"); diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 24c25d6364..e7db3f4044 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -189,17 +189,13 @@ fn docker_command_capture_stdout(args: [&str; N]) -> Result { Box::pin(self.custom_tool_call_output(call_id)).await } - ApplyPatchModelOutput::Shell - | ApplyPatchModelOutput::ShellViaHeredoc - | ApplyPatchModelOutput::ShellCommandViaHeredoc => { + ApplyPatchModelOutput::ShellCommandViaHeredoc => { Box::pin(self.function_call_stdout(call_id)).await } } diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 82113ee494..e42057215a 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -258,8 +258,6 @@ async fn apply_patch_cli_uses_codex_self_exe_with_linux_sandbox_helper_alias() - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_cli_multiple_operations_integration( output_type: ApplyPatchModelOutput, ) -> Result<()> { @@ -302,8 +300,6 @@ D delete.txt #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> Result<()> { skip_if_no_network!(Ok(())); @@ -329,8 +325,6 @@ async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_moves_file_to_new_directory( model_output: ApplyPatchModelOutput, @@ -357,8 +351,6 @@ async fn apply_patch_cli_moves_file_to_new_directory( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_updates_file_appends_trailing_newline( model_output: ApplyPatchModelOutput, @@ -385,8 +377,6 @@ async fn apply_patch_cli_updates_file_appends_trailing_newline( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_insert_only_hunk_modifies_file( model_output: ApplyPatchModelOutput, @@ -414,8 +404,6 @@ async fn apply_patch_cli_insert_only_hunk_modifies_file( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_move_overwrites_existing_destination( model_output: ApplyPatchModelOutput, @@ -445,8 +433,6 @@ async fn apply_patch_cli_move_overwrites_existing_destination( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( model_output: ApplyPatchModelOutput, @@ -484,8 +470,6 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_add_overwrites_existing_file( model_output: ApplyPatchModelOutput, @@ -511,8 +495,6 @@ async fn apply_patch_cli_add_overwrites_existing_file( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_rejects_invalid_hunk_header( model_output: ApplyPatchModelOutput, @@ -542,8 +524,6 @@ async fn apply_patch_cli_rejects_invalid_hunk_header( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_reports_missing_context( model_output: ApplyPatchModelOutput, @@ -577,8 +557,6 @@ async fn apply_patch_cli_reports_missing_context( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_reports_missing_target_file( model_output: ApplyPatchModelOutput, @@ -612,8 +590,6 @@ async fn apply_patch_cli_reports_missing_target_file( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_delete_missing_file_reports_error( model_output: ApplyPatchModelOutput, @@ -648,8 +624,6 @@ async fn apply_patch_cli_delete_missing_file_reports_error( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput) -> Result<()> { skip_if_no_network!(Ok(())); @@ -672,8 +646,6 @@ async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_delete_directory_reports_verification_error( model_output: ApplyPatchModelOutput, @@ -698,8 +670,6 @@ async fn apply_patch_cli_delete_directory_reports_verification_error( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_rejects_path_traversal_outside_workspace( model_output: ApplyPatchModelOutput, @@ -744,8 +714,6 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace( #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Shell ; "shell")] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc ; "shell_heredoc")] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")] async fn intercepted_apply_patch_verification_uses_local_sandbox( model_output: ApplyPatchModelOutput, @@ -797,8 +765,6 @@ async fn intercepted_apply_patch_verification_uses_local_sandbox( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform ; "freeform")] -#[test_case(ApplyPatchModelOutput::Shell ; "shell")] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc ; "shell_heredoc")] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")] async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace( model_output: ApplyPatchModelOutput, @@ -868,8 +834,6 @@ async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform ; "freeform")] -#[test_case(ApplyPatchModelOutput::Shell ; "shell")] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc ; "shell_heredoc")] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")] async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace( model_output: ApplyPatchModelOutput, @@ -972,8 +936,6 @@ async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( model_output: ApplyPatchModelOutput, @@ -1021,8 +983,6 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_verification_failure_has_no_side_effects( model_output: ApplyPatchModelOutput, @@ -1535,7 +1495,6 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch( model_output: ApplyPatchModelOutput, @@ -1558,8 +1517,6 @@ async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) -> Result<()> { skip_if_no_network!(Ok(())); @@ -1579,8 +1536,6 @@ async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_missing_second_chunk_context_rejected( model_output: ApplyPatchModelOutput, @@ -1615,8 +1570,6 @@ async fn apply_patch_cli_missing_second_chunk_context_rejected( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_emits_turn_diff_event_with_unified_diff( model_output: ApplyPatchModelOutput, @@ -1847,8 +1800,6 @@ async fn apply_patch_clears_aggregated_diff_after_inexact_delta() -> Result<()> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_change_context_disambiguates_target( model_output: ApplyPatchModelOutput, diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 156dd4a817..bcf4919737 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -2,7 +2,6 @@ use assert_cmd::Command as AssertCommand; use codex_git_utils::collect_git_info; use codex_login::CODEX_API_KEY_ENV_VAR; use codex_protocol::protocol::GitInfo; -use codex_utils_cargo_bin::find_resource; use core_test_support::fs_wait; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -16,9 +15,12 @@ fn repo_root() -> std::path::PathBuf { codex_utils_cargo_bin::repo_root().expect("failed to resolve repo root") } -fn cli_responses_fixture() -> std::path::PathBuf { - #[expect(clippy::expect_used)] - find_resource!("tests/cli_responses_fixture.sse").expect("failed to resolve fixture path") +fn cli_sse_response() -> String { + responses::sse(vec![ + responses::ev_response_created("resp-fixture"), + responses::ev_assistant_message("msg-fixture", "fixture hello"), + responses::ev_completed("resp-fixture"), + ]) } /// Tests streaming the Responses API through the CLI using a mock server. @@ -262,37 +264,36 @@ async fn exec_cli_profile_applies_model_instructions_file() { ); } -/// Tests streaming responses through the CLI using a local SSE fixture file. -/// This test: -/// 1. Uses a pre-recorded SSE response fixture instead of a live server -/// 2. Configures codex to read from this fixture via CODEX_RS_SSE_FIXTURE env var -/// 3. Sends a "hello?" prompt and verifies the response -/// 4. Ensures the fixture content is correctly streamed through the CLI +/// Tests streaming responses through the CLI using a local Responses API server. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_api_stream_cli() { skip_if_no_network!(); - let fixture = cli_responses_fixture(); + let server = MockServer::start().await; + let resp_mock = responses::mount_sse_once(&server, cli_sse_response()).await; let repo_root = repo_root(); let home = TempDir::new().unwrap(); let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); let mut cmd = AssertCommand::new(bin); + cmd.timeout(Duration::from_secs(30)); cmd.arg("exec") .arg("--skip-git-repo-check") .arg("-c") - .arg("openai_base_url=\"http://unused.local\"") + .arg(format!("openai_base_url=\"{}/v1\"", server.uri())) .arg("-C") .arg(&repo_root) .arg("hello?"); cmd.env("CODEX_HOME", home.path()) - .env("OPENAI_API_KEY", "dummy") - .env("CODEX_RS_SSE_FIXTURE", fixture); + .env("OPENAI_API_KEY", "dummy"); let output = cmd.output().unwrap(); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("fixture hello")); + + let request = resp_mock.single_request(); + assert_eq!(request.path(), "/v1/responses"); } /// End-to-end: create a session (writes rollout), verify the file, then resume and confirm append. @@ -308,23 +309,25 @@ async fn integration_creates_and_checks_session_file() -> anyhow::Result<()> { let marker = format!("integration-test-{}", Uuid::new_v4()); let prompt = format!("echo {marker}"); - // 3. Use the same offline SSE fixture as responses_api_stream_cli so the test is hermetic. - let fixture = cli_responses_fixture(); + // 3. Serve two hermetic SSE responses, one for the initial run and one for resume. + let server = MockServer::start().await; + let resp_mock = + responses::mount_sse_sequence(&server, vec![cli_sse_response(), cli_sse_response()]).await; let repo_root = repo_root(); // 4. Run the codex CLI and invoke `exec`, which is what records a session. let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); let mut cmd = AssertCommand::new(bin); + cmd.timeout(Duration::from_secs(30)); cmd.arg("exec") .arg("--skip-git-repo-check") .arg("-c") - .arg("openai_base_url=\"http://unused.local\"") + .arg(format!("openai_base_url=\"{}/v1\"", server.uri())) .arg("-C") .arg(&repo_root) .arg(&prompt); cmd.env("CODEX_HOME", home.path()) - .env(CODEX_API_KEY_ENV_VAR, "dummy") - .env("CODEX_RS_SSE_FIXTURE", &fixture); + .env(CODEX_API_KEY_ENV_VAR, "dummy"); let output = cmd.output().unwrap(); assert!( @@ -436,21 +439,22 @@ async fn integration_creates_and_checks_session_file() -> anyhow::Result<()> { let prompt2 = format!("echo {marker2}"); let bin2 = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); let mut cmd2 = AssertCommand::new(bin2); + cmd2.timeout(Duration::from_secs(30)); cmd2.arg("exec") .arg("--skip-git-repo-check") .arg("-c") - .arg("openai_base_url=\"http://unused.local\"") + .arg(format!("openai_base_url=\"{}/v1\"", server.uri())) .arg("-C") .arg(&repo_root) .arg(&prompt2) .arg("resume") .arg("--last"); cmd2.env("CODEX_HOME", home.path()) - .env("OPENAI_API_KEY", "dummy") - .env("CODEX_RS_SSE_FIXTURE", &fixture); + .env("OPENAI_API_KEY", "dummy"); let output2 = cmd2.output().unwrap(); assert!(output2.status.success(), "resume codex-cli run failed"); + assert_eq!(resp_mock.requests().len(), 2); // Find the new session file containing the resumed marker. let marker2_clone = marker2.clone(); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 510de6f1bf..8a461edc63 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -764,10 +764,8 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { let request = resp_mock.single_request(); assert_eq!(request.path(), "/v1/responses"); - let request_session_id = request.header("session_id").expect("session_id header"); - let request_session_id_hyphenated = request.header("session-id").expect("session-id header"); - let request_thread_id = request.header("thread_id").expect("thread_id header"); - let request_thread_id_hyphenated = request.header("thread-id").expect("thread-id header"); + let request_session_id = request.header("session-id").expect("session-id header"); + let request_thread_id = request.header("thread-id").expect("thread-id header"); let request_authorization = request .header("authorization") .expect("authorization header"); @@ -779,12 +777,7 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { let thread_id_string = expected_thread_id.to_string(); assert_eq!(request_session_id, expected_session_id.to_string()); - assert_eq!( - request_session_id_hyphenated, - expected_session_id.to_string() - ); assert_eq!(request_thread_id, thread_id_string.as_str()); - assert_eq!(request_thread_id_hyphenated, thread_id_string.as_str()); assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Test API Key"); assert_eq!( @@ -1046,20 +1039,13 @@ async fn chatgpt_auth_sends_correct_request() { .expect("chatgpt-account-id header"); let request_body = request.body_json(); - let request_session_id = request.header("session_id").expect("session_id header"); - let request_session_id_hyphenated = request.header("session-id").expect("session-id header"); - let request_thread_id = request.header("thread_id").expect("thread_id header"); - let request_thread_id_hyphenated = request.header("thread-id").expect("thread-id header"); + let request_session_id = request.header("session-id").expect("session-id header"); + let request_thread_id = request.header("thread-id").expect("thread-id header"); let installation_id = std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME)) .expect("read installation id"); assert_eq!(request_session_id, expected_session_id.to_string()); - assert_eq!( - request_session_id_hyphenated, - expected_session_id.to_string() - ); assert_eq!(request_thread_id, expected_thread_id.to_string()); - assert_eq!(request_thread_id_hyphenated, expected_thread_id.to_string()); assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Access Token"); @@ -2497,38 +2483,6 @@ async fn token_count_includes_rate_limits_snapshot() { .await .unwrap(); - let first_token_event = - wait_for_event(&codex, |msg| matches!(msg, EventMsg::TokenCount(_))).await; - let rate_limit_only = match first_token_event { - EventMsg::TokenCount(ev) => ev, - _ => unreachable!(), - }; - - let rate_limit_json = serde_json::to_value(&rate_limit_only).unwrap(); - pretty_assertions::assert_eq!( - rate_limit_json, - json!({ - "info": null, - "rate_limits": { - "limit_id": "codex", - "limit_name": null, - "primary": { - "used_percent": 12.5, - "window_minutes": 10, - "resets_at": 1704069000 - }, - "secondary": { - "used_percent": 40.0, - "window_minutes": 60, - "resets_at": 1704074400 - }, - "credits": null, - "plan_type": null, - "rate_limit_reached_type": null - } - }) - ); - let token_event = wait_for_event( &codex, |msg| matches!(msg, EventMsg::TokenCount(ev) if ev.info.is_some()), diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 489adb9a95..030aecd29e 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -132,11 +132,11 @@ async fn responses_websocket_streams_request() { Some(harness.thread_id.to_string()) ); assert_eq!( - handshake.header("session_id"), + handshake.header("session-id"), Some(harness.session_id.to_string()) ); assert_eq!( - handshake.header("thread_id"), + handshake.header("thread-id"), Some(harness.thread_id.to_string()) ); assert_eq!( diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index e3ea0669ca..5ce115cdaf 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -210,6 +210,60 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn collaboration_instructions_omitted_when_disabled() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config.include_collaboration_mode_instructions = false; + }); + let test = builder.build(&server).await?; + let collaboration_mode = collab_mode_with_instructions(Some("turn instructions")); + + test.codex + .submit(Op::UserTurn { + environments: None, + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + cwd: test.config.cwd.to_path_buf(), + approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, + sandbox_policy: test.config.legacy_sandbox_policy(), + permission_profile: None, + model: test.session_configured.model.clone(), + effort: None, + summary: Some( + test.config + .model_reasoning_summary + .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), + ), + service_tier: None, + collaboration_mode: Some(collaboration_mode), + final_output_json_schema: None, + personality: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + assert_eq!( + count_messages_containing(&dev_texts, COLLABORATION_MODE_OPEN_TAG), + 0 + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index b1620ee36b..68ddf1691b 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -26,7 +26,6 @@ use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; use core_test_support::hooks::trust_discovered_hooks; -use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::mount_models_once; use core_test_support::skip_if_no_network; @@ -78,6 +77,14 @@ const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD"; pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted."; +fn ev_shell_command_call(call_id: &str, command: &str) -> serde_json::Value { + ev_function_call( + call_id, + "shell_command", + &json!({ "command": command }).to_string(), + ) +} + fn disabled_permission_user_turn(text: impl Into, cwd: PathBuf, model: String) -> Op { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); @@ -954,7 +961,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // first chunk of work let model_reasoning_response_1_sse = sse(vec![ reasoning_response_1.clone(), - ev_local_shell_call("r1-shell", "completed", vec!["echo", "make-react"]), + ev_shell_command_call("r1-shell", "echo make-react"), ev_completed_with_tokens("r1", token_count_used), ]); @@ -972,7 +979,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // second chunk of work let model_reasoning_response_2_sse = sse(vec![ reasoning_response_2.clone(), - ev_local_shell_call("r3-shell", "completed", vec!["echo", "make-node"]), + ev_shell_command_call("r3-shell", "echo make-node"), ev_completed_with_tokens("r3", token_count_used), ]); @@ -990,7 +997,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // third chunk of work let model_reasoning_response_3_sse = sse(vec![ ev_reasoning_item("m6", &["I will create a python app"], &[]), - ev_local_shell_call("r6-shell", "completed", vec!["echo", "make-python"]), + ev_shell_command_call("r6-shell", "echo make-python"), ev_completed_with_tokens("r6", token_count_used), ]); @@ -1186,20 +1193,10 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { "type": "reasoning" }, { - "action": { - "command": [ - "echo", - "make-react" - ], - "env": null, - "timeout_ms": null, - "type": "exec", - "user": null, - "working_directory": null - }, + "arguments": "{\"command\":\"echo make-react\"}", "call_id": "r1-shell", - "status": "completed", - "type": "local_shell_call" + "name": "shell_command", + "type": "function_call" }, { "call_id": "r1-shell", @@ -1296,20 +1293,10 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { "type": "reasoning" }, { - "action": { - "command": [ - "echo", - "make-node" - ], - "env": null, - "timeout_ms": null, - "type": "exec", - "user": null, - "working_directory": null - }, + "arguments": "{\"command\":\"echo make-node\"}", "call_id": "r3-shell", - "status": "completed", - "type": "local_shell_call" + "name": "shell_command", + "type": "function_call" }, { "call_id": "r3-shell", @@ -1406,20 +1393,10 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { "type": "reasoning" }, { - "action": { - "command": [ - "echo", - "make-python" - ], - "env": null, - "timeout_ms": null, - "type": "exec", - "user": null, - "working_directory": null - }, + "arguments": "{\"command\":\"echo make-python\"}", "call_id": "r6-shell", - "status": "completed", - "type": "local_shell_call" + "name": "shell_command", + "type": "function_call" }, { "call_id": "r6-shell", diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index bec0ea436f..8849d80773 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -357,11 +357,11 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { Some("Bearer Access Token") ); assert_eq!( - compact_request.header("session_id").as_deref(), + compact_request.header("session-id").as_deref(), Some(session_id.as_str()) ); assert_eq!( - compact_request.header("thread_id").as_deref(), + compact_request.header("thread-id").as_deref(), Some(thread_id.as_str()) ); let compact_body = compact_request.body_json(); @@ -479,10 +479,9 @@ async fn assert_remote_manual_compact_request_parity( responses::ev_completed("turn-three-final-response"), ]), responses::sse(vec![ - responses::ev_local_shell_call( - "turn-four-local-shell", - "completed", - vec!["/bin/echo", "TURN_FOUR_LOCAL_SHELL"], + responses::ev_shell_command_call( + "turn-four-shell-command", + "echo TURN_FOUR_LOCAL_SHELL", ), responses::ev_completed("turn-four-local-shell-response"), ]), @@ -589,7 +588,7 @@ async fn assert_remote_manual_compact_request_parity( assert_eq!( response_requests.len(), 7, - "expected five turns with one unsupported tool continuation and one local shell continuation" + "expected five turns with one unsupported tool continuation and one shell command continuation" ); assert_eq!( compact_mock.requests().len(), @@ -1046,12 +1045,12 @@ async fn remote_compact_runs_automatically() -> Result<()> { assert_eq!( compact_mock .single_request() - .header("session_id") + .header("session-id") .as_deref(), Some(session_id.as_str()) ); assert_eq!( - compact_mock.single_request().header("thread_id").as_deref(), + compact_mock.single_request().header("thread-id").as_deref(), Some(thread_id.as_str()) ); let follow_up_request = responses_mock.single_request(); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 563719cec2..635aeaf488 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -2135,48 +2135,25 @@ async fn blocked_pre_tool_use_records_additional_context_for_shell_command() -> #[derive(Clone, Copy)] enum BashRewriteSurface { - ContainerExec, ExecCommand, - LocalShell, - Shell, ShellCommand, } impl BashRewriteSurface { fn slug(self) -> &'static str { match self { - BashRewriteSurface::ContainerExec => "container-exec", BashRewriteSurface::ExecCommand => "exec-command", - BashRewriteSurface::LocalShell => "local-shell", - BashRewriteSurface::Shell => "shell", BashRewriteSurface::ShellCommand => "shell-command", } } - fn tool_call(self, call_id: &str, command: &[String], command_text: &str) -> Result { + fn tool_call(self, call_id: &str, command_text: &str) -> Result { match self { - BashRewriteSurface::ContainerExec => Ok(ev_function_call( - call_id, - "container.exec", - &serde_json::to_string(&serde_json::json!({ "command": command }))?, - )), BashRewriteSurface::ExecCommand => Ok(ev_function_call( call_id, "exec_command", &serde_json::to_string(&serde_json::json!({ "cmd": command_text }))?, )), - BashRewriteSurface::LocalShell => { - Ok(core_test_support::responses::ev_local_shell_call( - call_id, - "completed", - command.iter().map(String::as_str).collect(), - )) - } - BashRewriteSurface::Shell => Ok(ev_function_call( - call_id, - "shell", - &serde_json::to_string(&serde_json::json!({ "command": command }))?, - )), BashRewriteSurface::ShellCommand => Ok(ev_function_call( call_id, "shell_command", @@ -2185,33 +2162,19 @@ impl BashRewriteSurface { } } - fn original_command(self, marker: &Path) -> (Vec, String) { - let command_text = format!("printf original > {}", marker.display()); + fn original_command(self, marker: &Path) -> String { match self { - BashRewriteSurface::ContainerExec - | BashRewriteSurface::LocalShell - | BashRewriteSurface::Shell => { - let command = vec!["/bin/sh".to_string(), "-c".to_string(), command_text]; - let command_text = codex_shell_command::parse_command::shlex_join(&command); - (command, command_text) - } BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => { - (Vec::new(), command_text) + format!("printf original > {}", marker.display()) } } } fn rewritten_command(self, marker: &Path) -> String { - let command_text = format!("printf rewritten > {}", marker.display()); match self { - BashRewriteSurface::ContainerExec - | BashRewriteSurface::LocalShell - | BashRewriteSurface::Shell => codex_shell_command::parse_command::shlex_join(&[ - "/bin/sh".to_string(), - "-c".to_string(), - command_text, - ]), - BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => command_text, + BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => { + format!("printf rewritten > {}", marker.display()) + } } } @@ -2234,14 +2197,14 @@ async fn assert_pre_tool_use_rewrites_bash_surface(surface: BashRewriteSurface) let call_id = format!("pretooluse-{slug}-rewrite"); let original_marker = std::env::temp_dir().join(format!("pretooluse-{slug}-original-marker")); let rewritten_marker = std::env::temp_dir().join(format!("pretooluse-{slug}-rewritten-marker")); - let (tool_command, original_command) = surface.original_command(&original_marker); + let original_command = surface.original_command(&original_marker); let rewritten_command = surface.rewritten_command(&rewritten_marker); let responses = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-1"), - surface.tool_call(&call_id, &tool_command, &original_command)?, + surface.tool_call(&call_id, &original_command)?, ev_completed("resp-1"), ]), sse(vec![ @@ -2295,21 +2258,6 @@ async fn assert_pre_tool_use_rewrites_bash_surface(surface: BashRewriteSurface) Ok(()) } -#[tokio::test] -async fn pre_tool_use_rewrites_shell_before_execution() -> Result<()> { - assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::Shell).await -} - -#[tokio::test] -async fn pre_tool_use_rewrites_container_exec_before_execution() -> Result<()> { - assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::ContainerExec).await -} - -#[tokio::test] -async fn pre_tool_use_rewrites_local_shell_before_execution() -> Result<()> { - assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::LocalShell).await -} - #[tokio::test] async fn pre_tool_use_rewrites_shell_command_before_execution() -> Result<()> { assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::ShellCommand).await @@ -2745,95 +2693,6 @@ async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> { Ok(()) } -#[tokio::test] -async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let call_id = "pretooluse-local-shell"; - let marker = std::env::temp_dir().join("pretooluse-local-shell-marker"); - let command = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - format!("printf blocked > {}", marker.display()), - ]; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - core_test_support::responses::ev_local_shell_call( - call_id, - "completed", - command.iter().map(String::as_str).collect(), - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "local shell blocked"), - ev_completed("resp-2"), - ]), - ], - ) - .await; - - let mut builder = test_codex() - .with_pre_build_hook(|home| { - if let Err(error) = - write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked local shell") - { - panic!("failed to write pre tool use hook test fixture: {error}"); - } - }) - .with_config(trust_discovered_hooks); - let test = builder.build(&server).await?; - - if marker.exists() { - fs::remove_file(&marker).context("remove leftover local shell marker")?; - } - - test.submit_turn("run the blocked local shell command") - .await?; - - let requests = responses.requests(); - assert_eq!(requests.len(), 2); - let output_item = requests[1].function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("local shell output string"); - assert!( - output.contains("Command blocked by PreToolUse hook: blocked local shell"), - "blocked local shell output should surface the hook reason", - ); - assert!( - output.contains(&format!( - "Command: {}", - codex_shell_command::parse_command::shlex_join(&command) - )), - "blocked local shell output should surface the blocked command", - ); - assert!( - !marker.exists(), - "blocked local shell command should not execute" - ); - - let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; - assert_eq!(hook_inputs.len(), 1); - assert_eq!( - hook_inputs[0]["tool_input"]["command"], - codex_shell_command::parse_command::shlex_join(&command), - ); - assert!( - hook_inputs[0]["turn_id"] - .as_str() - .is_some_and(|turn_id| !turn_id.is_empty()) - ); - - Ok(()) -} - #[tokio::test] async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> { skip_if_no_network!(Ok(())); @@ -3424,75 +3283,6 @@ async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_re Ok(()) } -#[tokio::test] -async fn post_tool_use_records_additional_context_for_local_shell() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let call_id = "posttooluse-local-shell"; - let command = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "printf local-post-tool-output".to_string(), - ]; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - core_test_support::responses::ev_local_shell_call( - call_id, - "completed", - command.iter().map(String::as_str).collect(), - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "local shell post hook context observed"), - ev_completed("resp-2"), - ]), - ], - ) - .await; - - let post_context = "Remember the local shell post-tool note."; - let mut builder = test_codex() - .with_pre_build_hook(|home| { - if let Err(error) = - write_post_tool_use_hook(home, Some("^Bash$"), "context", post_context) - { - panic!("failed to write post tool use hook test fixture: {error}"); - } - }) - .with_config(trust_discovered_hooks); - let test = builder.build(&server).await?; - - test.submit_turn("run the local shell command with post hook") - .await?; - - let requests = responses.requests(); - assert_eq!(requests.len(), 2); - assert!( - requests[1] - .message_input_texts("developer") - .contains(&post_context.to_string()), - "follow-up request should include local shell post tool use additional context", - ); - let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?; - assert_eq!(hook_inputs.len(), 1); - assert_eq!( - hook_inputs[0]["tool_input"]["command"], - codex_shell_command::parse_command::shlex_join(&command), - ); - assert_eq!( - hook_inputs[0]["tool_response"], - Value::String("local-post-tool-output".to_string()), - ); - - Ok(()) -} - #[tokio::test] async fn post_tool_use_exit_two_replaces_one_shot_exec_command_output_with_feedback() -> Result<()> { diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index a9aa843ad1..346c503c25 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -15,8 +15,8 @@ use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_response_created; +use core_test_support::responses::ev_shell_command_call; use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::skip_if_no_network; @@ -32,7 +32,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch const ETAG_1: &str = "\"models-etag-1\""; const ETAG_2: &str = "\"models-etag-2\""; - const CALL_ID: &str = "local-shell-call-1"; + const CALL_ID: &str = "shell-command-call-1"; let server = MockServer::start().await; @@ -81,7 +81,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch // It also includes a mismatched X-Models-Etag, which should trigger a /models refresh. let first_response_body = sse(vec![ ev_response_created("resp-1"), - ev_local_shell_call(CALL_ID, "completed", vec!["/bin/echo", "etag ok"]), + ev_shell_command_call(CALL_ID, "/bin/echo 'etag ok'"), ev_completed("resp-1"), ]); responses::mount_response_once( diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 5539599b24..03692ac547 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -11,7 +11,6 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_custom_tool_call; use core_test_support::responses::ev_function_call; -use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_message_item_added; use core_test_support::responses::ev_output_text_delta; use core_test_support::responses::ev_reasoning_item; @@ -72,6 +71,19 @@ fn assert_empty_mcp_tool_fields(line: &str) -> Result<(), String> { Ok(()) } +fn shell_command_call(call_id: &str, command: &str) -> serde_json::Value { + let args = serde_json::json!({ "command": command }).to_string(); + ev_function_call(call_id, "shell_command", &args) +} + +fn touch_command(path: &str) -> String { + if cfg!(windows) { + format!("New-Item -ItemType File -Path {path} -Force | Out-Null") + } else { + format!("/usr/bin/touch {path}") + } +} + #[test] fn extract_log_field_handles_empty_bare_values() { let line = "event.name=\"codex.tool_result\" mcp_server= mcp_server_origin="; @@ -891,7 +903,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { .await .unwrap(); - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; logs_assert(|lines: &[&str]| { let line = lines @@ -996,23 +1008,13 @@ async fn handle_response_item_records_tool_result_for_function_call() { #[tokio::test] #[traced_test] -async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() { +async fn handle_response_item_records_tool_result_for_shell_command_call() { let server = start_mock_server().await; mount_sse_once( &server, sse(vec![ - serde_json::json!({ - "type": "response.output_item.done", - "item": { - "type": "local_shell_call", - "status": "completed", - "action": { - "type": "exec", - "command": vec!["/bin/echo", "hello"], - } - } - }), + shell_command_call("shell-call", "echo shell"), ev_completed("done"), ]), ) @@ -1021,76 +1023,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), - ev_completed("done"), - ]), - ) - .await; - - let TestCodex { codex, .. } = test_codex() - .with_config(move |config| { - config - .features - .disable(Feature::GhostCommit) - .expect("test config should allow feature update"); - }) - .build(&server) - .await - .unwrap(); - - codex - .submit(Op::UserInput { - environments: None, - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - }) - .await - .unwrap(); - - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; - - logs_assert(|lines: &[&str]| { - let line = lines - .iter() - .find(|line| { - line.contains("codex.tool_result") - && line.contains(&"tool_name=local_shell".to_string()) - && line.contains("output=LocalShellCall without call_id or id") - }) - .ok_or_else(|| "missing codex.tool_result event".to_string())?; - - if !line.contains("success=false") { - return Err("missing success field".to_string()); - } - assert_empty_mcp_tool_fields(line)?; - - Ok(()) - }); -} - -#[cfg(target_os = "macos")] -#[tokio::test] -#[traced_test] -async fn handle_response_item_records_tool_result_for_local_shell_call() { - let server = start_mock_server().await; - - mount_sse_once( - &server, - sse(vec![ - ev_local_shell_call("shell-call", "completed", vec!["/bin/echo", "shell"]), - ev_completed("done"), - ]), - ) - .await; - - mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1129,10 +1062,10 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { .find(|line| line.contains("codex.tool_result") && line.contains("call_id=shell-call")) .ok_or_else(|| "missing codex.tool_result event".to_string())?; - if !line.contains("tool_name=local_shell") { + if !line.contains("tool_name=shell_command") { return Err("missing tool_name field".to_string()); } - if !line.contains("arguments=/bin/echo shell") { + if !line.contains("arguments={\"command\":\"echo shell\"}") { return Err("missing arguments field".to_string()); } let output_idx = line @@ -1168,8 +1101,8 @@ fn tool_decision_assertion<'a>( .ok_or_else(|| format!("missing codex.tool_decision event for {call_id}"))?; let lower = line.to_lowercase(); - if !lower.contains("tool_name=local_shell") { - return Err("missing tool_name for local_shell".to_string()); + if !lower.contains("tool_name=shell_command") { + return Err("missing tool_name for shell_command".to_string()); } if !lower.contains(&format!("decision={expected_decision}")) { return Err(format!("unexpected decision for {call_id}")); @@ -1184,16 +1117,12 @@ fn tool_decision_assertion<'a>( #[tokio::test] #[traced_test] -async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { +async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { let server = start_mock_server().await; mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "auto_config_call", - "completed", - vec!["/bin/echo", "local shell"], - ), + shell_command_call("auto_config_call", "echo local shell"), ev_completed("done"), ]), ) @@ -1202,7 +1131,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1244,16 +1173,13 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { #[tokio::test] #[traced_test] -async fn handle_container_exec_user_approved_records_tool_decision() { +async fn handle_shell_command_user_approved_records_tool_decision() { let server = start_mock_server().await; + let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "user_approved_call", - "completed", - vec!["/usr/bin/touch", "codex-otel-approval-test"], - ), + shell_command_call("user_approved_call", &command), ev_completed("done"), ]), ) @@ -1262,7 +1188,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1316,17 +1242,14 @@ async fn handle_container_exec_user_approved_records_tool_decision() { #[tokio::test] #[traced_test] -async fn handle_container_exec_user_approved_for_session_records_tool_decision() { +async fn handle_shell_command_user_approved_for_session_records_tool_decision() { let server = start_mock_server().await; + let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "user_approved_session_call", - "completed", - vec!["/usr/bin/touch", "codex-otel-approval-test"], - ), + shell_command_call("user_approved_session_call", &command), ev_completed("done"), ]), ) @@ -1334,7 +1257,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1390,15 +1313,12 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() #[traced_test] async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { let server = start_mock_server().await; + let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "sandbox_retry_call", - "completed", - vec!["/usr/bin/touch", "codex-otel-approval-test"], - ), + shell_command_call("sandbox_retry_call", &command), ev_completed("done"), ]), ) @@ -1406,7 +1326,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1460,17 +1380,14 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { #[tokio::test] #[traced_test] -async fn handle_container_exec_user_denies_records_tool_decision() { +async fn handle_shell_command_user_denies_records_tool_decision() { let server = start_mock_server().await; + let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "user_denied_call", - "completed", - vec!["/usr/bin/touch", "codex-otel-approval-test"], - ), + shell_command_call("user_denied_call", &command), ev_completed("done"), ]), ) @@ -1479,7 +1396,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1534,15 +1451,12 @@ async fn handle_container_exec_user_denies_records_tool_decision() { #[traced_test] async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() { let server = start_mock_server().await; + let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "sandbox_session_call", - "completed", - vec!["/usr/bin/touch", "codex-otel-approval-test"], - ), + shell_command_call("sandbox_session_call", &command), ev_completed("done"), ]), ) @@ -1550,7 +1464,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1606,15 +1520,12 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() #[traced_test] async fn handle_sandbox_error_user_denies_records_tool_decision() { let server = start_mock_server().await; + let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "sandbox_deny_call", - "completed", - vec!["/usr/bin/touch", "codex-otel-approval-test"], - ), + shell_command_call("sandbox_deny_call", &command), ev_completed("done"), ]), ) @@ -1623,7 +1534,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index b81bb06bb9..5b861ab219 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -179,13 +179,13 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { "update_plan", "request_user_input", "apply_patch", - "web_search", "view_image", "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", + "web_search", ]); let body0 = req1.single_request().body_json(); diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index c82a5a1672..6ddd9d0b5e 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -17,6 +17,7 @@ use core_test_support::responses; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::sse; @@ -30,6 +31,8 @@ use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use tokio::time::Duration; +use tokio::time::timeout; fn call_output(req: &ResponsesRequest, call_id: &str) -> String { let raw = req.function_call_output(call_id); @@ -118,6 +121,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul let first_response = sse(vec![ ev_response_created("resp-1"), ev_function_call(call_id, "request_user_input", &request_args), + ev_rate_limits(), ev_completed("resp-1"), ]); responses::mount_sse_once(&server, first_response).await; @@ -169,6 +173,22 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul assert_eq!(request.call_id, call_id); assert_eq!(request.questions.len(), 1); assert_eq!(request.questions[0].is_other, true); + assert!( + timeout(Duration::from_millis(200), async { + loop { + let event = match codex.next_event().await { + Ok(event) => event, + Err(err) => panic!("event stream should stay open: {err}"), + }; + if matches!(event.msg, EventMsg::TokenCount(_)) { + return; + } + } + }) + .await + .is_err(), + "TokenCount should wait until request_user_input resolves" + ); let mut answers = HashMap::new(); answers.insert( @@ -185,6 +205,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }) .await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::TokenCount(_))).await; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let req = second_mock.single_request(); @@ -202,6 +223,118 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul Ok(()) } +fn ev_rate_limits() -> Value { + json!({ + "type": "codex.rate_limits", + "plan_type": "plus", + "rate_limits": { + "allowed": true, + "limit_reached": false, + "primary": { + "used_percent": 42, + "window_minutes": 60, + "reset_at": 1700000000 + }, + "secondary": null + }, + "code_review_rate_limits": null, + "credits": null, + "promo": null + }) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_user_input_interrupt_emits_deferred_token_count() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let TestCodex { + codex, + cwd, + session_configured, + .. + } = test_codex().build(&server).await?; + + let call_id = "user-input-interrupt"; + let request_args = json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }) + .to_string(); + + let response = sse(vec![ + ev_response_created("resp-interrupt"), + ev_function_call(call_id, "request_user_input", &request_args), + ev_completed_with_tokens("resp-interrupt", /*total_tokens*/ 77), + ]); + responses::mount_sse_once(&server, response).await; + + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, cwd.path()); + codex + .submit(Op::UserTurn { + environments: None, + items: vec![UserInput::Text { + text: "please confirm".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + approvals_reviewer: None, + sandbox_policy, + permission_profile, + model: session_configured.model.clone(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: session_configured.model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + personality: None, + }) + .await?; + + let request = wait_for_event_match(&codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + + codex.submit(Op::Interrupt).await?; + + let token_count = wait_for_event_match(&codex, |event| match event { + EventMsg::TokenCount(token_count) => Some(token_count.clone()), + _ => None, + }) + .await; + assert_eq!( + token_count + .info + .map(|info| info.total_token_usage.total_tokens), + Some(77) + ); + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnAborted(_))).await; + + assert_eq!(request.call_id, call_id); + Ok(()) +} + async fn assert_request_user_input_rejected(mode_name: &str, build_mode: F) -> anyhow::Result<()> where F: FnOnce(String) -> CollaborationMode, diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index ab0fcba9b6..3211d336e5 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -109,7 +109,6 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { [ EventMsg::TurnStarted(_), EventMsg::UserMessage(_), - EventMsg::TokenCount(_), EventMsg::AgentMessage(_), EventMsg::TokenCount(_), EventMsg::TurnComplete(_), @@ -126,7 +125,6 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { [ EventMsg::TurnStarted(started), EventMsg::UserMessage(first_user), - EventMsg::TokenCount(_), EventMsg::AgentMessage(assistant_message), EventMsg::TokenCount(_), EventMsg::TurnComplete(completed), @@ -196,7 +194,6 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> [ EventMsg::TurnStarted(_), EventMsg::UserMessage(_), - EventMsg::TokenCount(_), EventMsg::AgentReasoning(_), EventMsg::AgentReasoningRawContent(_), EventMsg::AgentMessage(_), @@ -215,7 +212,6 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> [ EventMsg::TurnStarted(started), EventMsg::UserMessage(first_user), - EventMsg::TokenCount(_), EventMsg::AgentReasoning(reasoning), EventMsg::AgentReasoningRawContent(raw), EventMsg::AgentMessage(assistant_message), diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 51b3f2db1a..48a648d1bc 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -4,7 +4,6 @@ use std::path::Path; use std::path::PathBuf; use chrono::Utc; -use codex_core::EventPersistenceMode; use codex_core::RolloutRecorder; use codex_core::RolloutRecorderParams; use codex_core::config::ConfigBuilder; @@ -189,10 +188,7 @@ async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()> /*thread_source*/ None, BaseInstructions::default(), Vec::new(), - EventPersistenceMode::Limited, ), - /*state_db_ctx*/ None, - /*state_builder*/ None, ) .await?; recorder.persist().await?; diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index cc9e5cde16..f36304f5e4 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -24,6 +24,7 @@ use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_RESOURCE_URI; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; use core_test_support::responses::mount_sse_once; @@ -98,6 +99,18 @@ fn tool_search_output_tools(request: &ResponsesRequest, call_id: &str) -> Vec bool { + let output = json!({ + "tools": tool_search_output_tools(request, call_id), + }); + namespace_child_tool(&output, namespace, tool_name).is_some() +} + fn configure_search_capable_model(config: &mut Config) { let mut model_catalog = bundled_models_response() .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); @@ -1051,6 +1064,162 @@ 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_surfaced_mcp_tool_errors_are_returned_to_model() -> 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-rmcp-echo"; + let tool_call_id = "rmcp-echo-error"; + 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_function_call_with_namespace(tool_call_id, "mcp__rmcp__", "echo", "{}"), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-3"), + ]), + ], + ) + .await; + + let rmcp_test_server_bin = stdio_server_bin()?; + let mut builder = + configured_builder(apps_server.chatgpt_base_url.clone()).with_config(move |config| { + config + .features + .enable(Feature::ToolSearchAlwaysDeferMcpTools) + .expect("test config should allow feature update"); + 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, + provenance: Default::default(), + 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.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "Find the rmcp echo tool and call it.".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + + let EventMsg::McpToolCallEnd(end) = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::McpToolCallEnd(_)) + }) + .await + else { + unreachable!("event guard guarantees McpToolCallEnd"); + }; + assert_eq!(end.call_id, tool_call_id); + assert!(!end.is_success()); + let tool_error = end + .result + .as_ref() + .expect_err("rmcp echo error should stay in the MCP result"); + assert!( + tool_error.contains("tool call error:") + && tool_error.contains("missing field") + && tool_error.contains("message"), + "MCP invocation should report the execution failure: {tool_error}" + ); + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = mock.requests(); + assert_eq!(requests.len(), 3); + + let first_request_tools = tool_names(&requests[0].body_json()); + assert!( + first_request_tools + .iter() + .any(|name| name == TOOL_SEARCH_TOOL_NAME), + "first request should advertise tool_search: {first_request_tools:?}" + ); + assert!( + !first_request_tools.iter().any(|name| name == "mcp__rmcp__"), + "deferred rmcp namespace should not be directly exposed before search: {first_request_tools:?}" + ); + + assert!( + tool_search_output_has_namespace_child(&requests[1], search_call_id, "mcp__rmcp__", "echo"), + "tool_search should return the rmcp echo tool" + ); + + let output = requests[2].function_call_output(tool_call_id); + let output_text = match output.get("output") { + Some(Value::String(text)) => text.clone(), + Some(Value::Array(items)) => items + .iter() + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"), + other => panic!("unexpected MCP error output payload: {other:?}"), + }; + assert!( + output_text.contains("missing field") && output_text.contains("message"), + "MCP error output should be model visible: {output_text}" + ); + assert!( + !output_text.contains("unsupported call"), + "search-surfaced MCP calls should not fall through to unsupported call: {output_text}" + ); + + 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(())); @@ -1140,3 +1309,195 @@ async fn tool_search_uses_non_app_mcp_server_instructions_as_namespace_descripti Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_search_matches_mcp_tools_by_distinct_name_description_and_schema_terms() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + let query_cases = [ + ("tool-search-mcp-raw-name", "calendar_timezone_option_99"), + ("tool-search-mcp-description", "uploaded document"), + ("tool-search-mcp-schema", "starts_at"), + ]; + let mock = mount_sse_sequence( + &server, + vec![ + sse(std::iter::once(ev_response_created("resp-1")) + .chain(query_cases.into_iter().map(|(call_id, query)| { + ev_tool_search_call( + call_id, + &json!({ + "query": query, + "limit": 8, + }), + ) + })) + .chain(std::iter::once(ev_completed("resp-1"))) + .collect()), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); + let test = builder.build(&server).await?; + + test.submit_turn_with_approval_and_permission_profile( + "Search for calendar tooling.", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + + assert!( + tool_search_output_has_namespace_child( + &requests[1], + "tool-search-mcp-raw-name", + SEARCH_CALENDAR_NAMESPACE, + "_timezone_option_99" + ), + "expected raw MCP tool-name query to surface _timezone_option_99: {:?}", + tool_search_output_tools(&requests[1], "tool-search-mcp-raw-name") + ); + assert!( + tool_search_output_has_namespace_child( + &requests[1], + "tool-search-mcp-description", + SEARCH_CALENDAR_NAMESPACE, + "_extract_text" + ), + "expected MCP description query to surface _extract_text: {:?}", + tool_search_output_tools(&requests[1], "tool-search-mcp-description") + ); + assert!( + tool_search_output_has_namespace_child( + &requests[1], + "tool-search-mcp-schema", + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL + ), + "expected MCP schema query to surface {SEARCH_CALENDAR_CREATE_TOOL}: {:?}", + tool_search_output_tools(&requests[1], "tool-search-mcp-schema") + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_search_matches_dynamic_tools_by_name_description_namespace_and_schema_terms() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let query_cases = [ + ("tool-search-dynamic-name", "quasar_ping_beacon"), + ("tool-search-dynamic-spaces", "quasar ping beacon"), + ("tool-search-dynamic-description", "saffron metronome"), + ("tool-search-dynamic-namespace", "orbit_ops"), + ("tool-search-dynamic-schema", "chrono_spec"), + ]; + let mock = mount_sse_sequence( + &server, + vec![ + sse(std::iter::once(ev_response_created("resp-1")) + .chain(query_cases.into_iter().map(|(call_id, query)| { + ev_tool_search_call( + call_id, + &json!({ + "query": query, + "limit": 8, + }), + ) + })) + .chain(std::iter::once(ev_completed("resp-1"))) + .collect()), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let dynamic_tool = DynamicToolSpec { + namespace: Some("orbit_ops".to_string()), + name: "quasar_ping_beacon".to_string(), + description: "Trigger the saffron metronome workflow for reminder follow-ups.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "chrono_spec": { "type": "string" }, + "targetThreadId": { "type": "string" }, + }, + "required": ["chrono_spec"], + "additionalProperties": false, + }), + defer_loading: true, + }; + + let mut builder = test_codex().with_config(configure_search_capable_model); + let base_test = builder.build(&server).await?; + let new_thread = base_test + .thread_manager + .start_thread_with_tools( + base_test.config.clone(), + vec![dynamic_tool], + /*persist_extended_history*/ false, + ) + .await?; + let mut test = base_test; + test.codex = new_thread.thread; + test.session_configured = new_thread.session_configured; + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "Search for the dynamic tool".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + + for call_id in [ + "tool-search-dynamic-name", + "tool-search-dynamic-spaces", + "tool-search-dynamic-description", + "tool-search-dynamic-namespace", + "tool-search-dynamic-schema", + ] { + assert!( + tool_search_output_has_namespace_child( + &requests[1], + call_id, + "orbit_ops", + "quasar_ping_beacon" + ), + "expected query {call_id} to surface the quasar_ping_beacon tool: {:?}", + tool_search_output_tools(&requests[1], call_id) + ); + } + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 01b1563a6b..56afa59b73 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -7,7 +7,6 @@ use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; -use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -67,34 +66,6 @@ fn shell_responses( ]), ]) } - ShellModelOutput::Shell => { - let parameters = json!({ - "command": command, - "timeout_ms": 2_000, - }); - Ok(vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(¶meters)?), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ]) - } - ShellModelOutput::LocalShell => Ok(vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_local_shell_call(call_id, "completed", command), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ]), } } @@ -103,12 +74,8 @@ fn configure_shell_model( output_type: ShellModelOutput, include_apply_patch_tool: bool, ) -> TestCodexBuilder { - let builder = match (output_type, include_apply_patch_tool) { - (ShellModelOutput::ShellCommand, _) => builder.with_model("test-gpt-5-codex"), - (ShellModelOutput::LocalShell, true) => builder.with_model("gpt-5.4"), - (ShellModelOutput::Shell, true) => builder.with_model("gpt-5.4"), - (ShellModelOutput::LocalShell, false) => builder.with_model("test-local-shell-json"), - (ShellModelOutput::Shell, false) => builder.with_model("test-shell-json"), + let builder = match output_type { + ShellModelOutput::ShellCommand => builder.with_model("test-gpt-5-codex"), }; builder.with_config(move |config| { @@ -117,64 +84,7 @@ fn configure_shell_model( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] -#[test_case(ShellModelOutput::LocalShell)] -async fn shell_output_stays_json_without_freeform_apply_patch( - output_type: ShellModelOutput, -) -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = configure_shell_model( - test_codex(), - output_type, - /*include_apply_patch_tool*/ false, - ); - let test = builder.build(&server).await?; - - let call_id = "shell-json"; - let responses = shell_responses(call_id, vec!["/bin/echo", "shell json"], output_type)?; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "run the json shell command", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock.last_request().expect("shell output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("shell output string"); - - let mut parsed: Value = serde_json::from_str(output)?; - if let Some(metadata) = parsed.get_mut("metadata").and_then(Value::as_object_mut) { - let _ = metadata.remove("duration_seconds"); - } - - assert_eq!( - parsed - .get("metadata") - .and_then(|metadata| metadata.get("exit_code")) - .and_then(Value::as_i64), - Some(0), - "expected zero exit code in unformatted JSON output", - ); - let stdout = parsed - .get("output") - .and_then(Value::as_str) - .unwrap_or_default(); - assert_regex_match(r"(?s)^shell json\n?$", stdout); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] #[test_case(ShellModelOutput::ShellCommand)] -#[test_case(ShellModelOutput::LocalShell)] async fn shell_output_is_structured_with_freeform_apply_patch( output_type: ShellModelOutput, ) -> Result<()> { @@ -222,76 +132,7 @@ freeform shell } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] -#[test_case(ShellModelOutput::LocalShell)] -async fn shell_output_preserves_fixture_json_without_serialization( - output_type: ShellModelOutput, -) -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = configure_shell_model( - test_codex(), - output_type, - /*include_apply_patch_tool*/ false, - ); - let test = builder.build(&server).await?; - - let fixture_path = test.cwd.path().join("fixture.json"); - fs::write(&fixture_path, FIXTURE_JSON)?; - let fixture_path_str = fixture_path.to_string_lossy().to_string(); - - let call_id = "shell-json-fixture"; - let responses = shell_responses( - call_id, - vec!["/usr/bin/sed", "-n", "p", fixture_path_str.as_str()], - output_type, - )?; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "read the fixture JSON with sed", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock.last_request().expect("shell output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("shell output string"); - - let mut parsed: Value = serde_json::from_str(output)?; - if let Some(metadata) = parsed.get_mut("metadata").and_then(Value::as_object_mut) { - let _ = metadata.remove("duration_seconds"); - } - - assert_eq!( - parsed - .get("metadata") - .and_then(|metadata| metadata.get("exit_code")) - .and_then(Value::as_i64), - Some(0), - "expected zero exit code when serialization is disabled", - ); - let stdout = parsed - .get("output") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - assert_eq!( - stdout, FIXTURE_JSON, - "expected shell output to match the fixture contents" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] #[test_case(ShellModelOutput::ShellCommand)] -#[test_case(ShellModelOutput::LocalShell)] async fn shell_output_structures_fixture_with_serialization( output_type: ShellModelOutput, ) -> Result<()> { @@ -352,9 +193,7 @@ async fn shell_output_structures_fixture_with_serialization( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] #[test_case(ShellModelOutput::ShellCommand)] -#[test_case(ShellModelOutput::LocalShell)] async fn shell_output_for_freeform_tool_records_duration( output_type: ShellModelOutput, ) -> Result<()> { @@ -408,72 +247,8 @@ $"#; Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] -#[test_case(ShellModelOutput::LocalShell)] -async fn shell_output_reserializes_truncated_content(output_type: ShellModelOutput) -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = configure_shell_model( - test_codex(), - output_type, - /*include_apply_patch_tool*/ true, - ) - .with_config(move |config| { - config.tool_output_token_limit = Some(200); - }); - let test = builder.build(&server).await?; - - let call_id = "shell-truncated"; - let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "seq 1 400"], output_type)?; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "run the truncation shell command", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock - .last_request() - .expect("truncated output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("truncated output string"); - - assert!( - serde_json::from_str::(output).is_err(), - "expected truncated shell output to be plain text", - ); - let truncated_pattern = r#"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Total output lines: 400 -Output: -1 -2 -3 -4 -5 -6 -.*…46 tokens truncated….* -396 -397 -398 -399 -400 -$"#; - assert_regex_match(truncated_pattern, output); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_custom_tool_output_is_structured( output_type: ApplyPatchModelOutput, ) -> Result<()> { @@ -517,8 +292,6 @@ A {file_name} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_custom_tool_call_creates_file( output_type: ApplyPatchModelOutput, ) -> Result<()> { @@ -564,8 +337,6 @@ A {file_name} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_custom_tool_call_updates_existing_file( output_type: ApplyPatchModelOutput, ) -> Result<()> { @@ -616,8 +387,6 @@ M {file_name} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_custom_tool_call_reports_failure_output( output_type: ApplyPatchModelOutput, ) -> Result<()> { @@ -660,8 +429,6 @@ async fn apply_patch_custom_tool_call_reports_failure_output( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_tool_output_is_structured(output_type: ApplyPatchModelOutput) -> Result<()> { skip_if_no_network!(Ok(())); @@ -702,9 +469,7 @@ A {file_name} } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] #[test_case(ShellModelOutput::ShellCommand)] -#[test_case(ShellModelOutput::LocalShell)] async fn shell_output_is_structured_for_nonzero_exit(output_type: ShellModelOutput) -> Result<()> { skip_if_no_network!(Ok(())); @@ -897,52 +662,3 @@ Output: Ok(()) } - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn local_shell_call_output_is_structured() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_model("gpt-5.4").with_config(|config| { - config.include_apply_patch_tool = true; - }); - let test = builder.build(&server).await?; - - let call_id = "local-shell-call"; - let responses = vec![ - sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), - ev_local_shell_call(call_id, "completed", vec!["/bin/echo", "local shell"]), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "local shell done"), - ev_completed("resp-2"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "run the local shell command", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock - .last_request() - .expect("local shell output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("local shell output string"); - - let expected_pattern = r"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -local shell -?$"; - assert_regex_match(expected_pattern, output); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_user_input_no_preempt_after_reasoning.snap b/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_user_input_no_preempt_after_reasoning.snap index 724efd706a..d6bd5a7b22 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_user_input_no_preempt_after_reasoning.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_user_input_no_preempt_after_reasoning.snap @@ -16,5 +16,5 @@ Scenario: /responses POST bodies (input only, redacted like other suite snapshot 03:reasoning:summary=thinking:encrypted=true 04:function_call/shell 05:message/assistant:first answer -06:function_call_output:failed to parse function arguments: invalid type: string "echo preserved tool call", expected a sequence at line 1 column 37 +06:function_call_output:unsupported call: shell 07:message/user:second prompt diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index cc5a9952e3..5aab77cd7d 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -9,6 +9,7 @@ use codex_models_manager::manager::SharedModelsManager; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; @@ -52,6 +53,7 @@ fn test_model_info( visibility: ModelVisibility, default_reasoning_level: ReasoningEffort, supported_reasoning_levels: Vec, + service_tiers: Vec, ) -> ModelInfo { ModelInfo { slug: slug.to_string(), @@ -67,7 +69,7 @@ fn test_model_info( supports_search_tool: false, priority: 1, additional_speed_tiers: Vec::new(), - service_tiers: Vec::new(), + service_tiers, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, @@ -126,6 +128,11 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> description: "Deep dive".to_string(), }, ], + vec![ModelServiceTier { + id: "priority".to_string(), + name: "Fast".to_string(), + description: "1.5x speed, increased usage".to_string(), + }], ), test_model_info( "hidden-model", @@ -137,6 +144,7 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> effort: ReasoningEffort::Low, description: "Not visible".to_string(), }], + Vec::new(), ), ], }, @@ -195,6 +203,11 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> description.contains("low (Quick scan), high (Deep dive)."), "expected reasoning efforts in spawn_agent description: {description:?}" ); + assert!( + description + .contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage)."), + "expected service tier guidance in spawn_agent description: {description:?}" + ); assert!( !description.contains("Hidden Model"), "hidden picker model should be omitted from spawn_agent description: {description:?}" diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index a2017c99b3..ac41d06c3f 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -18,7 +18,6 @@ use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; -use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -66,12 +65,12 @@ fn custom_call_output(req: &ResponsesRequest, call_id: &str) -> (String, Option< } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> { +async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_model("test-local-shell-json"); + let mut builder = test_codex().with_model("test-gpt-5-codex"); let TestCodex { codex, cwd, @@ -79,11 +78,15 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> .. } = builder.build(&server).await?; - let call_id = "shell-tool-call"; - let command = vec!["/bin/echo", "tool harness"]; + let call_id = "shell-command-tool-call"; + let command_args = json!({ + "command": "echo tool harness", + "login": false, + }) + .to_string(); let first_response = sse(vec![ ev_response_created("resp-1"), - ev_local_shell_call(call_id, "completed", command), + ev_function_call(call_id, "shell_command", &command_args), ev_completed("resp-1"), ]); responses::mount_sse_once(&server, first_response).await; diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 3173581b73..01099e039f 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -351,24 +351,26 @@ async fn historical_unavailable_mcp_call_is_exposed_as_placeholder_tool() -> Res } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { +async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_model("test-shell-json"); + let mut builder = test_codex().with_model("test-gpt-5-codex"); let test = builder.build(&server).await?; - let command = ["/bin/echo", "shell ok"]; - let call_id_blocked = "shell-blocked"; - let call_id_success = "shell-success"; + let command = "echo shell ok"; + let call_id_blocked = "shell-command-blocked"; + let call_id_success = "shell-command-success"; let first_args = json!({ "command": command, + "login": false, "timeout_ms": 1_000, "sandbox_permissions": SandboxPermissions::RequireEscalated, }); let second_args = json!({ "command": command, + "login": false, "timeout_ms": 1_000, }); @@ -378,7 +380,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { ev_response_created("resp-1"), ev_function_call( call_id_blocked, - "shell", + "shell_command", &serde_json::to_string(&first_args)?, ), ev_completed("resp-1"), @@ -391,7 +393,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { ev_response_created("resp-2"), ev_function_call( call_id_success, - "shell", + "shell_command", &serde_json::to_string(&second_args)?, ), ev_completed("resp-2"), @@ -408,7 +410,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { .await; test.submit_turn_with_approval_and_permission_profile( - "run the shell command", + "run the shell_command script", AskForApproval::Never, PermissionProfile::Disabled, ) @@ -443,35 +445,32 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn sandbox_denied_shell_returns_original_output() -> Result<()> { +async fn sandbox_denied_shell_command_returns_original_output() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5.4"); let fixture = builder.build(&server).await?; - let call_id = "sandbox-denied-shell"; + let call_id = "sandbox-denied-shell-command"; let target_path = fixture.workspace_path("sandbox-denied.txt"); let sentinel = "sandbox-denied sentinel output"; - let command = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - format!( - "printf {sentinel:?}; printf {content:?} > {path:?}", - sentinel = format!("{sentinel}\n"), - content = "sandbox denied", - path = &target_path - ), - ]; + let command = format!( + "printf {sentinel:?}; printf {content:?} > {path:?}", + sentinel = format!("{sentinel}\n"), + content = "sandbox denied", + path = &target_path + ); let args = json!({ "command": command, + "login": false, "timeout_ms": 5_000, }); let responses = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -536,7 +535,7 @@ async fn sandbox_denied_shell_returns_original_output() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_enforces_glob_deny_read_policy() -> Result<()> { +async fn shell_command_enforces_glob_deny_read_policy() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -572,24 +571,22 @@ async fn shell_enforces_glob_deny_read_policy() -> Result<()> { fs::write(&denied_path, format!("{secret}\n")).context("write denied fixture")?; fs::write(&allowed_path, format!("{allowed}\n")).context("write allowed fixture")?; - let call_id = "shell-glob-deny-read"; - let command = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "status=0; cat \"$1\" || status=$?; cat \"$2\"; exit \"$status\"".to_string(), - "sh".to_string(), - denied_path.to_string_lossy().into_owned(), - allowed_path.to_string_lossy().into_owned(), - ]; + let call_id = "shell-command-glob-deny-read"; + let command = format!( + "rc=0; cat {denied_path:?} || rc=$?; cat {allowed_path:?}; exit \"$rc\"", + denied_path = denied_path.to_string_lossy(), + allowed_path = allowed_path.to_string_lossy(), + ); let args = json!({ "command": command, + "login": false, "timeout_ms": 1_000, }); let responses = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -706,17 +703,18 @@ async fn unified_exec_spec_toggle_end_to_end() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { +async fn shell_command_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_model("test-shell-json"); + let mut builder = test_codex().with_model("test-gpt-5-codex"); let test = builder.build(&server).await?; - let call_id = "shell-timeout"; + let call_id = "shell-command-timeout"; let timeout_ms = 50u64; let args = json!({ - "command": ["/bin/sh", "-c", "yes line | head -n 400; sleep 1"], + "command": "yes line | head -n 400; sleep 1", + "login": false, "timeout_ms": timeout_ms, }); @@ -724,7 +722,7 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { &server, sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) @@ -791,7 +789,7 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_timeout_handles_background_grandchild_stdout() -> Result<()> { +async fn shell_command_timeout_handles_background_grandchild_stdout() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -803,7 +801,7 @@ async fn shell_timeout_handles_background_grandchild_stdout() -> Result<()> { }); let test = builder.build(&server).await?; - let call_id = "shell-grandchild-timeout"; + let call_id = "shell-command-grandchild-timeout"; let pid_path = test.cwd.path().join("grandchild_pid.txt"); let script_path = test.cwd.path().join("spawn_detached.py"); let script = format!( @@ -820,7 +818,8 @@ time.sleep(60) fs::write(&script_path, script)?; let args = json!({ - "command": ["python3", script_path.to_string_lossy()], + "command": format!("python3 {:?}", script_path.to_string_lossy()), + "login": false, "timeout_ms": 200, }); @@ -828,7 +827,7 @@ time.sleep(60) &server, sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) @@ -885,82 +884,3 @@ time.sleep(60) Ok(()) } - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_spawn_failure_truncates_exec_error() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|cfg| { - cfg.permissions - .set_permission_profile(PermissionProfile::Disabled) - .expect("set permission profile"); - }); - let test = builder.build(&server).await?; - - let call_id = "shell-spawn-failure"; - let bogus_component = "missing-bin-".repeat(700); - let bogus_exe = test - .cwd - .path() - .join(bogus_component) - .to_string_lossy() - .into_owned(); - - let args = json!({ - "command": [bogus_exe], - "timeout_ms": 1_000, - }); - - mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), - ev_completed("resp-1"), - ]), - ) - .await; - let second_mock = mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ) - .await; - - test.submit_turn_with_approval_and_permission_profile( - "spawn a missing binary", - AskForApproval::Never, - PermissionProfile::Disabled, - ) - .await?; - - let failure_item = second_mock.single_request().function_call_output(call_id); - - let output = failure_item - .get("output") - .and_then(Value::as_str) - .expect("spawn failure output string"); - - let spawn_error_pattern = r#"(?s)^Exit code: -?\d+ -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -execution error: .*$"#; - let spawn_truncated_pattern = r#"(?s)^Exit code: -?\d+ -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Total output lines: \d+ -Output: - -execution error: .*$"#; - let spawn_error_regex = Regex::new(spawn_error_pattern)?; - let spawn_truncated_regex = Regex::new(spawn_truncated_pattern)?; - if !spawn_error_regex.is_match(output) && !spawn_truncated_regex.is_match(output) { - let fallback_pattern = r"(?s)^execution error: .*$"; - assert_regex_match(fallback_pattern, output); - } - assert!(output.len() <= 10 * 1024); - - Ok(()) -} diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index a1ae5e96b3..ae710a6607 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -120,7 +120,7 @@ allow = [ # Used by: transitive only "ISC", # MIT - https://opensource.org/license/mit - # Used by: allocative, ansi-to-tui, anyhow, arboard, assert_cmd, assert_matches, async-channel, async-stream, async-trait, axum, base64, bytes, chardetng, chrono, clap, clap_complete, color-eyre, crossterm, ctor, derive_more, diffy, dirs, dotenvy, encoding_rs, env-flags, env_logger, escargot, eventsource-stream, futures, http, ignore, image, indexmap, itertools, keyring, landlock, lazy_static, libc, log, lru, maplit, mime_guess, multimap, once_cell, openssl-sys, os_info, owo-colors, path-absolutize, pathdiff, portable-pty, predicates, pretty_assertions, pulldown-cmark, rand, ratatui, ratatui-macros, regex-lite, reqwest, rmcp, schemars, serde, serde_json, serde_with, serial_test, sha1, sha2, shlex, socket2, strum, strum_macros, sys-locale, tempfile, test-log, textwrap, thiserror, time, tiny_http, tokio, tokio-stream, tokio-test, tokio-util, toml, toml_edit, tonic, tracing, tracing-appender, tracing-subscriber, tracing-test, tree-sitter, tree-sitter-bash, tree-sitter-highlight, ts-rs, uds_windows, unicode-segmentation, unicode-width, url, urlencoding, uuid, vt100, walkdir, webbrowser, which, wildmatch, wiremock, zeroize + # Used by: allocative, ansi-to-tui, anyhow, arboard, assert_cmd, assert_matches, async-channel, async-stream, async-trait, axum, base64, bytes, chardetng, chrono, clap, clap_complete, color-eyre, crossterm, ctor, derive_more, diffy, dirs, dotenvy, encoding_rs, env_logger, escargot, eventsource-stream, futures, http, ignore, image, indexmap, itertools, keyring, landlock, lazy_static, libc, log, lru, maplit, mime_guess, multimap, once_cell, openssl-sys, os_info, owo-colors, path-absolutize, pathdiff, portable-pty, predicates, pretty_assertions, pulldown-cmark, rand, ratatui, ratatui-macros, regex-lite, reqwest, rmcp, schemars, serde, serde_json, serde_with, serial_test, sha1, sha2, shlex, socket2, strum, strum_macros, sys-locale, tempfile, test-log, textwrap, thiserror, time, tiny_http, tokio, tokio-stream, tokio-test, tokio-util, toml, toml_edit, tonic, tracing, tracing-appender, tracing-subscriber, tracing-test, tree-sitter, tree-sitter-bash, tree-sitter-highlight, ts-rs, uds_windows, unicode-segmentation, unicode-width, url, urlencoding, uuid, vt100, walkdir, webbrowser, which, wildmatch, wiremock, zeroize "MIT", # MIT-0 - https://opensource.org/license/mit-0 # Used by: dunce diff --git a/codex-rs/exec-server/BUILD.bazel b/codex-rs/exec-server/BUILD.bazel index 224536da8e..e94a5c0043 100644 --- a/codex-rs/exec-server/BUILD.bazel +++ b/codex-rs/exec-server/BUILD.bazel @@ -10,6 +10,9 @@ codex_rust_crate( # they install process-global test-binary dispatch state, and the remote # exec-server cases already rely on serialization around the full CLI path. integration_test_args = ["--test-threads=1"], + integration_compile_data_extra = [ + "src/proto/codex.exec_server.relay.v1.rs", + ], extra_binaries = [ "//codex-rs/bwrap:bwrap", ], diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 936fa412f1..09a9a71ea0 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -26,6 +26,7 @@ codex-utils-pty = { workspace = true } codex-utils-rustls-provider = { workspace = true } futures = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] } +prost = "0.14.3" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 81664eaca0..1eaf6e69eb 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -30,7 +30,65 @@ It requires a bearer token in `CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN`. Wire framing: -- websocket: one JSON-RPC message per websocket text frame +- local websocket: one JSON-RPC message per websocket frame +- remote websocket: binary protobuf relay frames carrying JSON-RPC payloads + +## Remote Relay Message Format + +In remote mode, the harness and executor communicate through rendezvous using +`codex.exec_server.relay.v1.RelayMessageFrame`; the checked-in schema is in +`src/proto/codex.exec_server.relay.v1.proto`. The relay frame carries stream +identity plus endpoint-owned reliability metadata: + +```text +version +stream_id +body // data | ack_frame | resume | reset | heartbeat +ack // highest contiguous peer segment seq received +ack_bits // bitset for peer segment seqs after ack +seq // data only: segment sequence number +segment_index // data only: 0-based index within message +segment_count // data only: number of segments in message +payload // data only: JSON-RPC message bytes or segment bytes +next_seq // resume only: next sender seq +reason // reset only: reset reason +``` + +`stream_id` identifies one virtual harness/executor JSON-RPC session on the +executor websocket. The harness generates a UUIDv4 `stream_id`; the executor +demuxes frames by `stream_id` and runs an independent `ConnectionProcessor` per +stream. + +Use segment-level sequence numbers for reliability: + +```text +seq = 0, 1, 2, 3, ... +``` + +Use contiguous segment sequence ranges to identify and stitch a segmented +application message: + +```text +message_start_seq = seq - segment_index +segment_index = 0 +segment_count = 1 +``` + +`message_start_seq` is derived by the receiver, not sent on the wire. For +unsplit messages, `message_start_seq == seq`, `segment_index == 0`, and +`segment_count == 1`. + +Use cumulative `ack` plus fixed-size `ack_bits` instead of variable ack ranges: + +```text +ack = highest contiguous received segment seq +bit i in ack_bits acknowledges seq = ack + 1 + i +``` + +Send `ack` and `ack_bits` redundantly on every outbound frame. Acks are not +themselves acked. Acks, retries, duplicate suppression, segmentation, and +reassembly are endpoint responsibilities; rendezvous only routes relay frames +by `stream_id`. ## Lifecycle diff --git a/codex-rs/exec-server/src/client_transport.rs b/codex-rs/exec-server/src/client_transport.rs index 8ca1eb0280..4bdc09a80e 100644 --- a/codex-rs/exec-server/src/client_transport.rs +++ b/codex-rs/exec-server/src/client_transport.rs @@ -15,6 +15,7 @@ use crate::client_api::RemoteExecServerConnectArgs; use crate::client_api::StdioExecServerCommand; use crate::client_api::StdioExecServerConnectArgs; use crate::connection::JsonRpcConnection; +use crate::relay::harness_connection_from_websocket; const ENVIRONMENT_CLIENT_NAME: &str = "codex-environment"; @@ -69,14 +70,13 @@ impl ExecServerClient { source, })?; - Self::connect( - JsonRpcConnection::from_websocket( - stream, - format!("exec-server websocket {websocket_url}"), - ), - args.into(), - ) - .await + let connection_label = format!("exec-server websocket {websocket_url}"); + let connection = if is_rendezvous_harness_url(&websocket_url) { + harness_connection_from_websocket(stream, connection_label) + } else { + JsonRpcConnection::from_websocket(stream, connection_label) + }; + Self::connect(connection, args.into()).await } pub(crate) async fn connect_stdio_command( @@ -120,6 +120,16 @@ impl ExecServerClient { } } +fn is_rendezvous_harness_url(websocket_url: &str) -> bool { + let Some((_path, query)) = websocket_url.split_once('?') else { + return false; + }; + query + .split('&') + .filter_map(|pair| pair.split_once('=')) + .any(|(key, value)| key == "role" && value == "harness") +} + fn stdio_command_process(stdio_command: &StdioExecServerCommand) -> Command { let mut command = Command::new(&stdio_command.program); command.args(&stdio_command.args); diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index d8c147127c..872f16ce32 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -13,6 +13,8 @@ mod local_process; mod process; mod process_id; mod protocol; +mod relay; +mod relay_proto; mod remote; mod remote_file_system; mod remote_process; diff --git a/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.proto b/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.proto new file mode 100644 index 0000000000..46527d80cc --- /dev/null +++ b/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package codex.exec_server.relay.v1; + +message RelayMessageFrame { + uint32 version = 1; + string stream_id = 2; + uint32 ack = 3; + uint32 ack_bits = 4; + + oneof body { + RelayData data = 5; + RelayAck ack_frame = 6; + RelayResume resume = 7; + RelayReset reset = 8; + RelayHeartbeat heartbeat = 9; + } +} + +message RelayData { + uint32 seq = 1; + uint32 segment_index = 2; + uint32 segment_count = 3; + bytes payload = 4; +} + +message RelayAck {} + +message RelayResume { + uint32 next_seq = 1; +} + +message RelayReset { + string reason = 1; +} + +message RelayHeartbeat {} diff --git a/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.rs b/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.rs new file mode 100644 index 0000000000..072a003dac --- /dev/null +++ b/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.rs @@ -0,0 +1,54 @@ +// This file is @generated by prost-build. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RelayMessageFrame { + #[prost(uint32, tag = "1")] + pub version: u32, + #[prost(string, tag = "2")] + pub stream_id: ::prost::alloc::string::String, + #[prost(uint32, tag = "3")] + pub ack: u32, + #[prost(uint32, tag = "4")] + pub ack_bits: u32, + #[prost(oneof = "relay_message_frame::Body", tags = "5, 6, 7, 8, 9")] + pub body: ::core::option::Option, +} +pub mod relay_message_frame { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Body { + #[prost(message, tag = "5")] + Data(super::RelayData), + #[prost(message, tag = "6")] + AckFrame(super::RelayAck), + #[prost(message, tag = "7")] + Resume(super::RelayResume), + #[prost(message, tag = "8")] + Reset(super::RelayReset), + #[prost(message, tag = "9")] + Heartbeat(super::RelayHeartbeat), + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RelayData { + #[prost(uint32, tag = "1")] + pub seq: u32, + #[prost(uint32, tag = "2")] + pub segment_index: u32, + #[prost(uint32, tag = "3")] + pub segment_count: u32, + #[prost(bytes = "vec", tag = "4")] + pub payload: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RelayAck {} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RelayResume { + #[prost(uint32, tag = "1")] + pub next_seq: u32, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RelayReset { + #[prost(string, tag = "1")] + pub reason: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RelayHeartbeat {} diff --git a/codex-rs/exec-server/src/relay.rs b/codex-rs/exec-server/src/relay.rs new file mode 100644 index 0000000000..bce787cfc2 --- /dev/null +++ b/codex-rs/exec-server/src/relay.rs @@ -0,0 +1,455 @@ +use std::collections::HashMap; + +use codex_app_server_protocol::JSONRPCMessage; +use futures::SinkExt; +use futures::StreamExt; +use prost::Message as ProstMessage; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; +use tokio::sync::mpsc; +use tokio::sync::watch; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Message; +use tracing::debug; +use tracing::warn; +use uuid::Uuid; + +use crate::ExecServerError; +use crate::connection::CHANNEL_CAPACITY; +use crate::connection::JsonRpcConnection; +use crate::connection::JsonRpcConnectionEvent; +use crate::connection::JsonRpcTransport; +use crate::relay_proto::RelayData; +use crate::relay_proto::RelayMessageFrame; +use crate::relay_proto::RelayResume; +use crate::relay_proto::relay_message_frame; +use crate::server::ConnectionProcessor; + +const RELAY_MESSAGE_FRAME_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum RelayFrameBodyKind { + Data, + Ack, + Resume, + Reset, + Heartbeat, +} + +impl RelayMessageFrame { + fn data(stream_id: String, seq: u32, payload: Vec) -> Self { + Self { + version: RELAY_MESSAGE_FRAME_VERSION, + stream_id, + ack: 0, + ack_bits: 0, + body: Some(relay_message_frame::Body::Data(RelayData { + seq, + segment_index: 0, + segment_count: 1, + payload, + })), + } + } + + fn resume(stream_id: String) -> Self { + Self { + version: RELAY_MESSAGE_FRAME_VERSION, + stream_id, + ack: 0, + ack_bits: 0, + body: Some(relay_message_frame::Body::Resume(RelayResume { + next_seq: 0, + })), + } + } + + fn validate(&self) -> Result { + if self.version != RELAY_MESSAGE_FRAME_VERSION { + return Err(ExecServerError::Protocol(format!( + "unsupported relay message frame version {}", + self.version + ))); + } + if self.stream_id.trim().is_empty() { + return Err(ExecServerError::Protocol( + "relay message frame is missing stream_id".to_string(), + )); + } + match self.body.as_ref() { + Some(relay_message_frame::Body::Data(data)) => { + if data.segment_index != 0 || data.segment_count != 1 || data.payload.is_empty() { + return Err(ExecServerError::Protocol( + "relay data message frame is missing required fields".to_string(), + )); + } + Ok(RelayFrameBodyKind::Data) + } + Some(relay_message_frame::Body::AckFrame(_)) => Ok(RelayFrameBodyKind::Ack), + Some(relay_message_frame::Body::Resume(_)) => Ok(RelayFrameBodyKind::Resume), + Some(relay_message_frame::Body::Reset(reset)) => { + if reset.reason.is_empty() { + return Err(ExecServerError::Protocol( + "relay reset message frame is missing reason".to_string(), + )); + } + Ok(RelayFrameBodyKind::Reset) + } + Some(relay_message_frame::Body::Heartbeat(_)) => Ok(RelayFrameBodyKind::Heartbeat), + None => Err(ExecServerError::Protocol( + "relay message frame is missing body".to_string(), + )), + } + } + + fn into_jsonrpc_message(self) -> Result { + let kind = self.validate()?; + if kind != RelayFrameBodyKind::Data { + return Err(ExecServerError::Protocol( + "expected relay data message frame".to_string(), + )); + } + let payload = match self.body { + Some(relay_message_frame::Body::Data(data)) => data.payload, + _ => Vec::new(), + }; + serde_json::from_slice(&payload).map_err(ExecServerError::Json) + } + + fn into_reset_reason(self) -> Option { + match self.body { + Some(relay_message_frame::Body::Reset(reset)) if !reset.reason.is_empty() => { + Some(reset.reason) + } + _ => None, + } + } +} + +fn encode_relay_message_frame(frame: &RelayMessageFrame) -> Vec { + frame.encode_to_vec() +} + +fn decode_relay_message_frame(payload: &[u8]) -> Result { + RelayMessageFrame::decode(payload) + .map_err(|err| ExecServerError::Protocol(format!("invalid relay message frame: {err}"))) +} + +fn jsonrpc_payload(message: &JSONRPCMessage) -> Result, ExecServerError> { + serde_json::to_vec(message).map_err(ExecServerError::Json) +} + +pub(crate) fn harness_connection_from_websocket( + stream: WebSocketStream, + connection_label: String, +) -> JsonRpcConnection +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let stream_id = Uuid::new_v4().to_string(); + let (mut websocket_writer, mut websocket_reader) = stream.split(); + let (outgoing_tx, mut outgoing_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (incoming_tx, incoming_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (disconnected_tx, disconnected_rx) = watch::channel(false); + + let reader_label = connection_label; + let reader_stream_id = stream_id.clone(); + let incoming_tx_for_reader = incoming_tx; + let disconnected_tx_for_reader = disconnected_tx.clone(); + let reader_task = tokio::spawn(async move { + loop { + match websocket_reader.next().await { + Some(Ok(Message::Binary(payload))) => { + let frame = match decode_relay_message_frame(payload.as_ref()) { + Ok(frame) => frame, + Err(err) => { + let _ = incoming_tx_for_reader + .send(JsonRpcConnectionEvent::MalformedMessage { + reason: format!( + "failed to parse relay message frame from {reader_label}: {err}" + ), + }) + .await; + continue; + } + }; + if frame.stream_id != reader_stream_id { + continue; + } + let kind = match frame.validate() { + Ok(kind) => kind, + Err(err) => { + let _ = incoming_tx_for_reader + .send(JsonRpcConnectionEvent::MalformedMessage { + reason: err.to_string(), + }) + .await; + continue; + } + }; + match kind { + RelayFrameBodyKind::Data => match frame.into_jsonrpc_message() { + Ok(message) => { + if incoming_tx_for_reader + .send(JsonRpcConnectionEvent::Message(message)) + .await + .is_err() + { + break; + } + } + Err(err) => { + let _ = incoming_tx_for_reader + .send(JsonRpcConnectionEvent::MalformedMessage { + reason: err.to_string(), + }) + .await; + } + }, + RelayFrameBodyKind::Reset => { + let _ = disconnected_tx_for_reader.send(true); + let _ = incoming_tx_for_reader + .send(JsonRpcConnectionEvent::Disconnected { + reason: frame.into_reset_reason(), + }) + .await; + break; + } + RelayFrameBodyKind::Ack + | RelayFrameBodyKind::Resume + | RelayFrameBodyKind::Heartbeat => {} + } + } + Some(Ok(Message::Close(_))) | None => { + let _ = disconnected_tx_for_reader.send(true); + let _ = incoming_tx_for_reader + .send(JsonRpcConnectionEvent::Disconnected { reason: None }) + .await; + break; + } + Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => {} + Some(Ok(Message::Text(_))) => { + let _ = incoming_tx_for_reader + .send(JsonRpcConnectionEvent::MalformedMessage { + reason: "relay exec-server transport expects binary protobuf frames" + .to_string(), + }) + .await; + } + Some(Err(err)) => { + let _ = disconnected_tx_for_reader.send(true); + let _ = incoming_tx_for_reader + .send(JsonRpcConnectionEvent::Disconnected { + reason: Some(format!( + "failed to read relay websocket frame from {reader_label}: {err}" + )), + }) + .await; + break; + } + } + } + }); + + let writer_task = tokio::spawn(async move { + let resume = RelayMessageFrame::resume(stream_id.clone()); + if websocket_writer + .send(Message::Binary(encode_relay_message_frame(&resume).into())) + .await + .is_err() + { + let _ = disconnected_tx.send(true); + return; + } + + let mut next_seq = 0u32; + while let Some(message) = outgoing_rx.recv().await { + let payload = match jsonrpc_payload(&message) { + Ok(payload) => payload, + Err(err) => { + warn!("failed to serialize JSON-RPC payload for relay transport: {err}"); + break; + } + }; + let frame = RelayMessageFrame::data(stream_id.clone(), next_seq, payload); + next_seq = next_seq.wrapping_add(1); + if websocket_writer + .send(Message::Binary(encode_relay_message_frame(&frame).into())) + .await + .is_err() + { + let _ = disconnected_tx.send(true); + break; + } + } + }); + + JsonRpcConnection { + outgoing_tx, + incoming_rx, + disconnected_rx, + task_handles: vec![reader_task, writer_task], + transport: JsonRpcTransport::Plain, + } +} + +pub(crate) async fn run_multiplexed_executor( + stream: WebSocketStream, + processor: ConnectionProcessor, +) where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let (mut websocket_writer, mut websocket_reader) = stream.split(); + let (physical_outgoing_tx, mut physical_outgoing_rx) = + mpsc::channel::>(CHANNEL_CAPACITY); + let writer_task = tokio::spawn(async move { + while let Some(encoded) = physical_outgoing_rx.recv().await { + if websocket_writer + .send(Message::Binary(encoded.into())) + .await + .is_err() + { + break; + } + } + }); + + let mut streams: HashMap = HashMap::new(); + loop { + let frame = match websocket_reader.next().await { + Some(Ok(Message::Binary(payload))) => { + match decode_relay_message_frame(payload.as_ref()) { + Ok(frame) => frame, + Err(err) => { + warn!("dropping malformed relay message frame from harness: {err}"); + continue; + } + } + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => continue, + Some(Ok(Message::Text(_))) => { + warn!("dropping non-binary relay message frame from harness"); + continue; + } + Some(Err(err)) => { + debug!("multiplexed executor websocket read failed: {err}"); + break; + } + }; + + let kind = match frame.validate() { + Ok(kind) => kind, + Err(err) => { + warn!("dropping invalid relay message frame: {err}"); + continue; + } + }; + + match kind { + RelayFrameBodyKind::Data => { + let stream_id = frame.stream_id.clone(); + let message = match frame.into_jsonrpc_message() { + Ok(message) => message, + Err(err) => { + warn!("dropping malformed relay data message frame: {err}"); + continue; + } + }; + let stream = streams.entry(stream_id.clone()).or_insert_with(|| { + spawn_virtual_stream( + stream_id.clone(), + processor.clone(), + physical_outgoing_tx.clone(), + ) + }); + if stream + .incoming_tx + .send(JsonRpcConnectionEvent::Message(message)) + .await + .is_err() + { + streams.remove(&stream_id); + } + } + RelayFrameBodyKind::Reset => { + if let Some(stream) = streams.remove(&frame.stream_id) { + stream.disconnect(frame.into_reset_reason()).await; + } + } + RelayFrameBodyKind::Ack + | RelayFrameBodyKind::Resume + | RelayFrameBodyKind::Heartbeat => {} + } + } + + for (_stream_id, stream) in streams { + stream.disconnect(/*reason*/ None).await; + } + drop(physical_outgoing_tx); + let _ = writer_task.await; +} + +struct VirtualStream { + incoming_tx: mpsc::Sender, + disconnected_tx: watch::Sender, +} + +impl VirtualStream { + async fn disconnect(self, reason: Option) { + let _ = self.disconnected_tx.send(true); + let _ = self + .incoming_tx + .send(JsonRpcConnectionEvent::Disconnected { reason }) + .await; + } +} + +fn spawn_virtual_stream( + stream_id: String, + processor: ConnectionProcessor, + physical_outgoing_tx: mpsc::Sender>, +) -> VirtualStream { + let (json_outgoing_tx, mut json_outgoing_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (incoming_tx, incoming_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (disconnected_tx, disconnected_rx) = watch::channel(false); + + let writer_stream_id = stream_id; + let writer_task = tokio::spawn(async move { + let mut next_seq = 0u32; + while let Some(message) = json_outgoing_rx.recv().await { + let payload = match jsonrpc_payload(&message) { + Ok(payload) => payload, + Err(err) => { + warn!("failed to serialize virtual stream JSON-RPC payload: {err}"); + break; + } + }; + let frame = RelayMessageFrame::data(writer_stream_id.clone(), next_seq, payload); + next_seq = next_seq.wrapping_add(1); + if physical_outgoing_tx + .send(encode_relay_message_frame(&frame)) + .await + .is_err() + { + break; + } + } + }); + + let connection = JsonRpcConnection { + outgoing_tx: json_outgoing_tx, + incoming_rx, + disconnected_rx, + task_handles: vec![writer_task], + transport: JsonRpcTransport::Plain, + }; + tokio::spawn(async move { + processor.run_connection(connection).await; + }); + + VirtualStream { + incoming_tx, + disconnected_tx, + } +} diff --git a/codex-rs/exec-server/src/relay_proto.rs b/codex-rs/exec-server/src/relay_proto.rs new file mode 100644 index 0000000000..b8a938b8c7 --- /dev/null +++ b/codex-rs/exec-server/src/relay_proto.rs @@ -0,0 +1,7 @@ +#[path = "proto/codex.exec_server.relay.v1.rs"] +mod generated; + +pub(crate) use generated::RelayData; +pub(crate) use generated::RelayMessageFrame; +pub(crate) use generated::RelayResume; +pub(crate) use generated::relay_message_frame; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index bb22105c19..32c0d5bc8e 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -11,7 +11,7 @@ use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use crate::ExecServerError; use crate::ExecServerRuntimePaths; -use crate::connection::JsonRpcConnection; +use crate::relay::run_multiplexed_executor; use crate::server::ConnectionProcessor; pub const CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR: &str = @@ -113,7 +113,7 @@ impl RemoteExecutorConfig { Self::with_bearer_token(base_url, executor_id, read_remote_bearer_token_from_env()?) } - fn with_bearer_token( + pub fn with_bearer_token( base_url: String, executor_id: String, bearer_token: String, @@ -150,12 +150,7 @@ pub async fn run_remote_executor( match connect_async(response.url.as_str()).await { Ok((websocket, _)) => { backoff = Duration::from_secs(1); - processor - .run_connection(JsonRpcConnection::from_websocket( - websocket, - "remote exec-server websocket".to_string(), - )) - .await; + run_multiplexed_executor(websocket, processor.clone()).await; } Err(err) => { warn!("failed to connect remote exec-server websocket: {err}"); diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs new file mode 100644 index 0000000000..d228db9ee5 --- /dev/null +++ b/codex-rs/exec-server/tests/relay.rs @@ -0,0 +1,353 @@ +mod common; + +#[path = "../src/proto/codex.exec_server.relay.v1.rs"] +mod relay_proto; + +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use codex_app_server_protocol::JSONRPCError; +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 codex_exec_server::ExecServerRuntimePaths; +use codex_exec_server::InitializeParams; +use codex_exec_server::InitializeResponse; +use codex_exec_server::RemoteExecutorConfig; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use prost::Message as ProstMessage; +use relay_proto::RelayData; +use relay_proto::RelayMessageFrame; +use relay_proto::RelayReset; +use relay_proto::relay_message_frame; +use tokio::net::TcpListener; +use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; +use uuid::Uuid; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const EXECUTOR_ID: &str = "exec-mux-test"; +const REGISTRY_TOKEN: &str = "registry-token"; +const RELAY_MESSAGE_FRAME_VERSION: u32 = 1; +const TEST_TIMEOUT: Duration = Duration::from_secs(5); + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multiplexed_remote_executor_routes_independent_virtual_streams() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let rendezvous_url = format!("ws://{}", listener.local_addr()?); + let registry = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/cloud/executor/{EXECUTOR_ID}/register"))) + .and(header("authorization", format!("Bearer {REGISTRY_TOKEN}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "executor_id": EXECUTOR_ID, + "url": rendezvous_url, + }))) + .mount(®istry) + .await; + + let (codex_exe, codex_linux_sandbox_exe) = common::current_test_binary_helper_paths()?; + let runtime_paths = ExecServerRuntimePaths::new(codex_exe, codex_linux_sandbox_exe)?; + let config = RemoteExecutorConfig::with_bearer_token( + registry.uri(), + EXECUTOR_ID.to_string(), + REGISTRY_TOKEN.to_string(), + )?; + let remote_executor = tokio::spawn(codex_exec_server::run_remote_executor( + config, + runtime_paths, + )); + + let (socket, _peer_addr) = timeout(TEST_TIMEOUT, listener.accept()) + .await + .context("remote executor should connect to fake rendezvous")??; + let mut websocket = timeout(TEST_TIMEOUT, accept_async(socket)) + .await + .context("fake rendezvous should accept executor websocket")??; + + let stream_a = "stream-a"; + let stream_b = "stream-b"; + send_relay_message( + &mut websocket, + stream_a, + /*seq*/ 0, + initialize_request(/*id*/ 1, "relay-test-a")?, + ) + .await?; + send_relay_message( + &mut websocket, + stream_b, + /*seq*/ 0, + initialize_request(/*id*/ 1, "relay-test-b")?, + ) + .await?; + + let initialize_responses = read_relay_messages_by_stream(&mut websocket, /*count*/ 2).await?; + let session_a = + assert_initialize_response(initialize_responses.get(stream_a), stream_a, /*id*/ 1)?; + let session_b = + assert_initialize_response(initialize_responses.get(stream_b), stream_b, /*id*/ 1)?; + assert_ne!(session_a, session_b); + + send_relay_message( + &mut websocket, + stream_a, + /*seq*/ 1, + notification("initialized", serde_json::json!({})), + ) + .await?; + send_relay_message( + &mut websocket, + stream_b, + /*seq*/ 1, + notification("initialized", serde_json::json!({})), + ) + .await?; + + send_relay_message( + &mut websocket, + stream_a, + /*seq*/ 2, + request(/*id*/ 2, "test/unknown-a", serde_json::json!({})), + ) + .await?; + send_relay_message( + &mut websocket, + stream_b, + /*seq*/ 2, + request(/*id*/ 2, "test/unknown-b", serde_json::json!({})), + ) + .await?; + + let unknown_method_responses = + read_relay_messages_by_stream(&mut websocket, /*count*/ 2).await?; + assert_error_response( + unknown_method_responses.get(stream_a), + stream_a, + /*id*/ 2, + "test/unknown-a", + )?; + assert_error_response( + unknown_method_responses.get(stream_b), + stream_b, + /*id*/ 2, + "test/unknown-b", + )?; + + send_relay_reset(&mut websocket, stream_a, "test_reset").await?; + send_relay_message( + &mut websocket, + stream_b, + /*seq*/ 3, + request( + /*id*/ 3, + "test/unknown-b-after-reset", + serde_json::json!({}), + ), + ) + .await?; + + let (stream_id, message) = read_relay_message(&mut websocket).await?; + assert_eq!(stream_id, stream_b); + assert_error_response( + Some(&message), + stream_b, + /*id*/ 3, + "test/unknown-b-after-reset", + )?; + + websocket.close(None).await?; + remote_executor.abort(); + let _ = remote_executor.await; + Ok(()) +} + +async fn send_relay_message( + websocket: &mut WebSocketStream, + stream_id: &str, + seq: u32, + message: JSONRPCMessage, +) -> Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let payload = serde_json::to_vec(&message)?; + let frame = RelayMessageFrame { + version: RELAY_MESSAGE_FRAME_VERSION, + stream_id: stream_id.to_string(), + ack: 0, + ack_bits: 0, + body: Some(relay_message_frame::Body::Data(RelayData { + seq, + segment_index: 0, + segment_count: 1, + payload, + })), + }; + send_relay_frame(websocket, frame).await +} + +async fn send_relay_reset( + websocket: &mut WebSocketStream, + stream_id: &str, + reason: &str, +) -> Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + send_relay_frame( + websocket, + RelayMessageFrame { + version: RELAY_MESSAGE_FRAME_VERSION, + stream_id: stream_id.to_string(), + ack: 0, + ack_bits: 0, + body: Some(relay_message_frame::Body::Reset(RelayReset { + reason: reason.to_string(), + })), + }, + ) + .await +} + +async fn send_relay_frame( + websocket: &mut WebSocketStream, + frame: RelayMessageFrame, +) -> Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + websocket + .send(Message::Binary(frame.encode_to_vec().into())) + .await?; + Ok(()) +} + +async fn read_relay_messages_by_stream( + websocket: &mut WebSocketStream, + count: usize, +) -> Result> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let mut messages = HashMap::new(); + for _ in 0..count { + let (stream_id, message) = read_relay_message(websocket).await?; + if messages.insert(stream_id.clone(), message).is_some() { + bail!("received duplicate response for stream {stream_id}"); + } + } + Ok(messages) +} + +async fn read_relay_message( + websocket: &mut WebSocketStream, +) -> Result<(String, JSONRPCMessage)> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + loop { + let frame = timeout(TEST_TIMEOUT, websocket.next()) + .await + .context("timed out waiting for relay frame")? + .ok_or_else(|| anyhow!("executor websocket closed"))??; + match frame { + Message::Binary(bytes) => { + let frame = RelayMessageFrame::decode(bytes.as_ref())?; + let stream_id = frame.stream_id; + let Some(relay_message_frame::Body::Data(data)) = frame.body else { + continue; + }; + let message = serde_json::from_slice(&data.payload)?; + return Ok((stream_id, message)); + } + Message::Ping(_) | Message::Pong(_) => {} + Message::Close(_) => bail!("executor websocket closed"), + Message::Text(_) => bail!("executor sent text frame on relay websocket"), + Message::Frame(_) => {} + } + } +} + +fn initialize_request(id: i64, client_name: &str) -> Result { + Ok(request( + id, + "initialize", + serde_json::to_value(InitializeParams { + client_name: client_name.to_string(), + resume_session_id: None, + })?, + )) +} + +fn request(id: i64, method: &str, params: serde_json::Value) -> JSONRPCMessage { + JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(id), + method: method.to_string(), + params: Some(params), + trace: None, + }) +} + +fn notification(method: &str, params: serde_json::Value) -> JSONRPCMessage { + JSONRPCMessage::Notification(JSONRPCNotification { + method: method.to_string(), + params: Some(params), + }) +} + +fn assert_initialize_response( + message: Option<&JSONRPCMessage>, + stream_id: &str, + id: i64, +) -> Result { + let message = message.ok_or_else(|| anyhow!("missing initialize response for {stream_id}"))?; + let JSONRPCMessage::Response(JSONRPCResponse { + id: response_id, + result, + }) = message + else { + bail!("expected initialize response for {stream_id}, got {message:?}"); + }; + assert_eq!(response_id, &RequestId::Integer(id)); + let response: InitializeResponse = serde_json::from_value(result.clone())?; + Ok(Uuid::parse_str(&response.session_id)?) +} + +fn assert_error_response( + message: Option<&JSONRPCMessage>, + stream_id: &str, + id: i64, + expected_method: &str, +) -> Result<()> { + let message = message.ok_or_else(|| anyhow!("missing error response for {stream_id}"))?; + let JSONRPCMessage::Error(JSONRPCError { + id: response_id, + error, + }) = message + else { + bail!("expected error response for {stream_id}, got {message:?}"); + }; + assert_eq!(response_id, &RequestId::Integer(id)); + assert!( + error.message.contains(expected_method), + "expected error for {stream_id} to mention {expected_method}, got {}", + error.message + ); + Ok(()) +} diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 2b12898c3c..3a5ebfd1ba 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -16,6 +16,10 @@ pub struct Cli { #[command(subcommand)] pub command: Option, + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", global = true, default_value_t = false)] + pub strict_config: bool, + #[clap(flatten)] pub shared: ExecSharedCliOptions, @@ -155,6 +159,7 @@ fn mark_exec_global_args(cmd: clap::Command) -> clap::Command { .mut_arg("dangerously_bypass_approvals_and_sandbox", |arg| { arg.global(true) }) + .mut_arg("bypass_hook_trust", |arg| arg.global(true)) } #[derive(Debug, clap::Subcommand)] @@ -257,7 +262,7 @@ impl FromArgMatches for ResumeArgs { } } -#[derive(Parser, Debug)] +#[derive(Args, Debug)] pub struct ReviewArgs { /// Review staged, unstaged, and untracked changes. #[arg( diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 3d49c40cc0..7825d163c9 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -54,6 +54,7 @@ use codex_app_server_protocol::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; use codex_cloud_requirements::cloud_requirements_loader_for_storage; use codex_config::ConfigLoadError; +use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; use codex_config::format_config_error_with_source; use codex_core::StateDbHandle; @@ -62,7 +63,7 @@ use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; -use codex_core::config::load_config_as_toml_with_cli_and_loader_overrides; +use codex_core::config::load_config_as_toml_with_cli_and_load_options; use codex_core::config::resolve_oss_provider; use codex_core::find_thread_meta_by_name_str; use codex_core::format_exec_policy_error_with_source; @@ -242,6 +243,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let Cli { command, + strict_config, shared, skip_git_repo_check, ephemeral, @@ -264,6 +266,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result config_profile, sandbox_mode: sandbox_mode_cli_arg, dangerously_bypass_approvals_and_sandbox, + bypass_hook_trust, cwd, add_dir, } = shared; @@ -317,18 +320,20 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result } }; - #[allow(clippy::print_stderr)] let loader_overrides = LoaderOverrides { ignore_user_config, ignore_user_and_project_exec_policy_rules: ignore_rules, ..Default::default() }; - let config_toml = match load_config_as_toml_with_cli_and_loader_overrides( + let config_toml = match load_config_as_toml_with_cli_and_load_options( &codex_home, Some(&config_cwd), cli_kv_overrides.clone(), - loader_overrides.clone(), + ConfigLoadOptions { + loader_overrides: loader_overrides.clone(), + strict_config, + }, ) .await { @@ -422,6 +427,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, ephemeral: ephemeral.then_some(true), + bypass_hook_trust: bypass_hook_trust.then_some(true), additional_writable_roots: add_dir, }; @@ -429,6 +435,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .cli_overrides(cli_kv_overrides) .harness_overrides(overrides) .loader_overrides(loader_overrides) + .strict_config(strict_config) .cloud_requirements(cloud_requirements) .build() .await?; @@ -520,6 +527,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result config: std::sync::Arc::new(config.clone()), cli_overrides: run_cli_overrides, loader_overrides: run_loader_overrides, + strict_config, cloud_requirements: run_cloud_requirements, feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/exec/src/main.rs b/codex-rs/exec/src/main.rs index 79a681b146..61eaecdd0a 100644 --- a/codex-rs/exec/src/main.rs +++ b/codex-rs/exec/src/main.rs @@ -32,8 +32,7 @@ fn main() -> anyhow::Result<()> { let mut inner = top_cli.inner; inner .config_overrides - .raw_overrides - .splice(0..0, top_cli.config_overrides.raw_overrides); + .prepend_root_overrides(top_cli.config_overrides); run_main(inner, arg0_paths).await?; Ok(()) diff --git a/codex-rs/exec/src/main_tests.rs b/codex-rs/exec/src/main_tests.rs index a9cb0ec633..5c0a8a3bfc 100644 --- a/codex-rs/exec/src/main_tests.rs +++ b/codex-rs/exec/src/main_tests.rs @@ -7,6 +7,7 @@ fn top_cli_parses_resume_prompt_after_config_flag() { let cli = TopCli::parse_from([ "codex-exec", "resume", + "--strict-config", "--last", "--json", "--model", @@ -17,8 +18,12 @@ fn top_cli_parses_resume_prompt_after_config_flag() { "--skip-git-repo-check", PROMPT, ]); + let mut inner = cli.inner; + inner + .config_overrides + .prepend_root_overrides(cli.config_overrides); - let Some(codex_exec::Command::Resume(args)) = cli.inner.command else { + let Some(codex_exec::Command::Resume(args)) = inner.command.as_ref() else { panic!("expected resume command"); }; let effective_prompt = args.prompt.clone().or_else(|| { @@ -29,9 +34,10 @@ fn top_cli_parses_resume_prompt_after_config_flag() { } }); assert_eq!(effective_prompt.as_deref(), Some(PROMPT)); - assert_eq!(cli.config_overrides.raw_overrides.len(), 1); + assert_eq!(inner.config_overrides.raw_overrides.len(), 1); assert_eq!( - cli.config_overrides.raw_overrides[0], + inner.config_overrides.raw_overrides[0], "reasoning_level=xhigh" ); + assert!(inner.strict_config); } diff --git a/codex-rs/exec/tests/fixtures/cli_responses_fixture.sse b/codex-rs/exec/tests/fixtures/cli_responses_fixture.sse deleted file mode 100644 index 660c56b1d8..0000000000 --- a/codex-rs/exec/tests/fixtures/cli_responses_fixture.sse +++ /dev/null @@ -1,10 +0,0 @@ -event: response.created -data: {"type":"response.created","response":{"id":"resp1"}} - -event: response.output_item.done -data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"fixture hello"}]}} - -event: response.completed -data: {"type":"response.completed","response":{"id":"resp1","output":[]}} - - diff --git a/codex-rs/exec/tests/suite/ephemeral.rs b/codex-rs/exec/tests/suite/ephemeral.rs index e6921f53af..ee3016fa65 100644 --- a/codex-rs/exec/tests/suite/ephemeral.rs +++ b/codex-rs/exec/tests/suite/ephemeral.rs @@ -1,9 +1,19 @@ #![cfg(not(target_os = "windows"))] #![allow(clippy::expect_used, clippy::unwrap_used)] -use codex_utils_cargo_bin::find_resource; +use core_test_support::responses; +use core_test_support::skip_if_no_network; use core_test_support::test_codex_exec::test_codex_exec; use walkdir::WalkDir; +use wiremock::MockServer; + +fn exec_sse_response() -> String { + responses::sse(vec![ + responses::ev_response_created("resp-ephemeral"), + responses::ev_assistant_message("msg-ephemeral", "ephemeral response"), + responses::ev_completed("resp-ephemeral"), + ]) +} fn session_rollout_count(home_path: &std::path::Path) -> usize { let sessions_dir = home_path.join("sessions"); @@ -19,13 +29,15 @@ fn session_rollout_count(home_path: &std::path::Path) -> usize { .count() } -#[test] -fn persists_rollout_file_by_default() -> anyhow::Result<()> { - let test = test_codex_exec(); - let fixture = find_resource!("tests/fixtures/cli_responses_fixture.sse")?; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn persists_rollout_file_by_default() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + let test = test_codex_exec(); + let server = MockServer::start().await; + let _response_mock = responses::mount_sse_once(&server, exec_sse_response()).await; + + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("default persistence behavior") .assert() @@ -35,13 +47,15 @@ fn persists_rollout_file_by_default() -> anyhow::Result<()> { Ok(()) } -#[test] -fn does_not_persist_rollout_file_in_ephemeral_mode() -> anyhow::Result<()> { - let test = test_codex_exec(); - let fixture = find_resource!("tests/fixtures/cli_responses_fixture.sse")?; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn does_not_persist_rollout_file_in_ephemeral_mode() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + let test = test_codex_exec(); + let server = MockServer::start().await; + let _response_mock = responses::mount_sse_once(&server, exec_sse_response()).await; + + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("--ephemeral") .arg("ephemeral behavior") diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index cfaa7aa81a..2bf6416ccc 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use anyhow::Context; -use codex_utils_cargo_bin::find_resource; +use core_test_support::responses; +use core_test_support::skip_if_no_network; use core_test_support::test_codex_exec::test_codex_exec; use pretty_assertions::assert_eq; use serde_json::Value; @@ -8,6 +9,7 @@ use std::string::ToString; use tempfile::TempDir; use uuid::Uuid; use walkdir::WalkDir; +use wiremock::MockServer; /// Utility: scan the sessions dir for a rollout file that contains `marker` /// in any response_item.message.content entry. Returns the absolute path. @@ -104,26 +106,41 @@ fn last_user_image_count(path: &std::path::Path) -> usize { last_count } -fn exec_fixture() -> anyhow::Result { - Ok(find_resource!("tests/fixtures/cli_responses_fixture.sse")?) -} - fn exec_repo_root() -> anyhow::Result { Ok(codex_utils_cargo_bin::repo_root()?) } -#[test] -fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> { +fn exec_sse_response(index: usize) -> String { + let response_id = format!("resp-exec-{index}"); + let message_id = format!("msg-exec-{index}"); + responses::sse(vec![ + responses::ev_response_created(&response_id), + responses::ev_assistant_message(&message_id, "exec response"), + responses::ev_completed(&response_id), + ]) +} + +async fn mount_exec_responses( + server: &MockServer, + count: usize, +) -> core_test_support::responses::ResponseMock { + responses::mount_sse_sequence(server, (0..count).map(exec_sse_response).collect()).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let test = test_codex_exec(); - let fixture = exec_fixture()?; + let server = MockServer::start().await; + let _response_mock = mount_exec_responses(&server, /*count*/ 2).await; let repo_root = exec_repo_root()?; // 1) First run: create a session with a unique marker in the content. let marker = format!("resume-last-{}", Uuid::new_v4()); let prompt = format!("echo {marker}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(&repo_root) @@ -140,8 +157,7 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> { let marker2 = format!("resume-last-2-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(&repo_root) @@ -164,18 +180,20 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> { Ok(()) } -#[test] -fn exec_resume_last_accepts_prompt_after_flag_in_json_mode() -> anyhow::Result<()> { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_resume_last_accepts_prompt_after_flag_in_json_mode() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let test = test_codex_exec(); - let fixture = exec_fixture()?; + let server = MockServer::start().await; + let _response_mock = mount_exec_responses(&server, /*count*/ 2).await; let repo_root = exec_repo_root()?; // 1) First run: create a session with a unique marker in the content. let marker = format!("resume-last-json-{}", Uuid::new_v4()); let prompt = format!("echo {marker}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(&repo_root) @@ -192,8 +210,7 @@ fn exec_resume_last_accepts_prompt_after_flag_in_json_mode() -> anyhow::Result<( let marker2 = format!("resume-last-json-2-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(&repo_root) @@ -216,18 +233,20 @@ fn exec_resume_last_accepts_prompt_after_flag_in_json_mode() -> anyhow::Result<( Ok(()) } -#[test] -fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let test = test_codex_exec(); - let fixture = exec_fixture()?; + let server = MockServer::start().await; + let _response_mock = mount_exec_responses(&server, /*count*/ 5).await; let dir_a = TempDir::new()?; let dir_b = TempDir::new()?; let marker_a = format!("resume-cwd-a-{}", Uuid::new_v4()); let prompt_a = format!("echo {marker_a}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(dir_a.path()) @@ -237,8 +256,7 @@ fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> { let marker_b = format!("resume-cwd-b-{}", Uuid::new_v4()); let prompt_b = format!("echo {marker_b}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(dir_b.path()) @@ -260,8 +278,7 @@ fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> { let session_id_b = extract_conversation_id(&path_b); let marker_b_touch = format!("resume-cwd-b-touch-{}", Uuid::new_v4()); let prompt_b_touch = format!("echo {marker_b_touch}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(dir_b.path()) @@ -278,8 +295,7 @@ fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> { let marker_b2 = format!("resume-cwd-b-2-{}", Uuid::new_v4()); let prompt_b2 = format!("echo {marker_b2}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(dir_a.path()) @@ -299,8 +315,7 @@ fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> { let marker_a2 = format!("resume-cwd-a-2-{}", Uuid::new_v4()); let prompt_a2 = format!("echo {marker_a2}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(dir_a.path()) @@ -323,24 +338,29 @@ fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> { Ok(()) } -#[test] -fn exec_resume_accepts_global_flags_after_subcommand() -> anyhow::Result<()> { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_resume_accepts_global_flags_after_subcommand() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let test = test_codex_exec(); - let fixture = exec_fixture()?; + let server = MockServer::start().await; + let _response_mock = mount_exec_responses(&server, /*count*/ 2).await; // Seed a session. - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("echo seed-resume-session") .assert() .success(); // Resume while passing global flags after the subcommand to ensure clap accepts them. + let base = format!("{}/v1", server.uri()); + let base_config = format!("openai_base_url={}", serde_json::to_string(&base)?); test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) .arg("resume") .arg("--last") + .arg("--config") + .arg(base_config) .arg("--json") .arg("--model") .arg("gpt-5.2-codex") @@ -355,18 +375,20 @@ fn exec_resume_accepts_global_flags_after_subcommand() -> anyhow::Result<()> { Ok(()) } -#[test] -fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let test = test_codex_exec(); - let fixture = exec_fixture()?; + let server = MockServer::start().await; + let _response_mock = mount_exec_responses(&server, /*count*/ 2).await; let repo_root = exec_repo_root()?; // 1) First run: create a session let marker = format!("resume-by-id-{}", Uuid::new_v4()); let prompt = format!("echo {marker}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(&repo_root) @@ -387,8 +409,7 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { let marker2 = format!("resume-by-id-2-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(&repo_root) @@ -410,17 +431,19 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { Ok(()) } -#[test] -fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let test = test_codex_exec(); - let fixture = exec_fixture()?; + let server = MockServer::start().await; + let _response_mock = mount_exec_responses(&server, /*count*/ 2).await; let repo_root = exec_repo_root()?; let marker = format!("resume-config-{}", Uuid::new_v4()); let prompt = format!("echo {marker}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("--sandbox") .arg("workspace-write") @@ -440,8 +463,7 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> { let prompt2 = format!("echo {marker2}"); let output = test - .cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + .cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("--sandbox") .arg("workspace-write") @@ -484,17 +506,19 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> { Ok(()) } -#[test] -fn exec_resume_accepts_images_after_subcommand() -> anyhow::Result<()> { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_resume_accepts_images_after_subcommand() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let test = test_codex_exec(); - let fixture = exec_fixture()?; + let server = MockServer::start().await; + let _response_mock = mount_exec_responses(&server, /*count*/ 2).await; let repo_root = exec_repo_root()?; let marker = format!("resume-image-{}", Uuid::new_v4()); let prompt = format!("echo {marker}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(&repo_root) @@ -516,8 +540,7 @@ fn exec_resume_accepts_images_after_subcommand() -> anyhow::Result<()> { let marker2 = format!("resume-image-2-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); - test.cmd() - .env("CODEX_RS_SSE_FIXTURE", &fixture) + test.cmd_with_server(&server) .arg("--skip-git-repo-check") .arg("-C") .arg(&repo_root) diff --git a/codex-rs/ext/extension-api/Cargo.toml b/codex-rs/ext/extension-api/Cargo.toml index b281d438ef..ceecc48023 100644 --- a/codex-rs/ext/extension-api/Cargo.toml +++ b/codex-rs/ext/extension-api/Cargo.toml @@ -14,5 +14,5 @@ doctest = false workspace = true [dependencies] -codex-tool-api = { workspace = true } codex-protocol = { workspace = true } +codex-tools = { workspace = true } diff --git a/codex-rs/ext/extension-api/examples/enabled_extensions.rs b/codex-rs/ext/extension-api/examples/enabled_extensions.rs index 9a267d6919..9027c28241 100644 --- a/codex-rs/ext/extension-api/examples/enabled_extensions.rs +++ b/codex-rs/ext/extension-api/examples/enabled_extensions.rs @@ -1,6 +1,12 @@ #[path = "enabled_extensions/shared_state_extension.rs"] mod shared_state_extension; +use std::future::Future; +use std::pin::pin; +use std::task::Context; +use std::task::Poll; +use std::task::Waker; + use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; use shared_state_extension::recorded_style_contributions; @@ -13,14 +19,26 @@ fn main() { let registry = builder.build(); // 2. The host decides which stores are shared. - let session_store = ExtensionData::new(); - let first_thread_store = ExtensionData::new(); - let second_thread_store = ExtensionData::new(); + let session_store = ExtensionData::new("session"); + let first_thread_store = ExtensionData::new("thread-1"); + let second_thread_store = ExtensionData::new("thread-2"); // 3. Reusing the same session store shares session state across threads. - let first_thread_fragments = contribute_prompt(®istry, &session_store, &first_thread_store); - contribute_prompt(®istry, &session_store, &first_thread_store); - contribute_prompt(®istry, &session_store, &second_thread_store); + let first_thread_fragments = block_on_ready(contribute_prompt( + ®istry, + &session_store, + &first_thread_store, + )); + block_on_ready(contribute_prompt( + ®istry, + &session_store, + &first_thread_store, + )); + block_on_ready(contribute_prompt( + ®istry, + &session_store, + &second_thread_store, + )); println!("first prompt fragments: {}", first_thread_fragments.len()); println!( @@ -49,14 +67,27 @@ fn main() { ); } -fn contribute_prompt( +async fn contribute_prompt( registry: &codex_extension_api::ExtensionRegistry<()>, session_store: &ExtensionData, thread_store: &ExtensionData, ) -> Vec { - registry - .context_contributors() - .iter() - .flat_map(|contributor| contributor.contribute(session_store, thread_store)) - .collect() + let mut fragments = Vec::new(); + for contributor in registry.context_contributors() { + fragments.extend(contributor.contribute(session_store, thread_store).await); + } + fragments +} + +fn block_on_ready(future: F) -> F::Output +where + F: Future, +{ + let waker = Waker::noop(); + let mut context = Context::from_waker(waker); + let mut future = pin!(future); + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => output, + Poll::Pending => panic!("example context contributors should complete immediately"), + } } diff --git a/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs b/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs index 25ab027c88..531f65b99e 100644 --- a/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs +++ b/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs @@ -17,17 +17,19 @@ pub fn install(registry: &mut ExtensionRegistryBuilder<()>) { struct StyleContributor; impl ContextContributor for StyleContributor { - fn contribute( - &self, - session_store: &ExtensionData, - thread_store: &ExtensionData, - ) -> Vec { - contribution_counts(session_store).record_style(); - contribution_counts(thread_store).record_style(); + fn contribute<'a>( + &'a self, + session_store: &'a ExtensionData, + thread_store: &'a ExtensionData, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + contribution_counts(session_store).record_style(); + contribution_counts(thread_store).record_style(); - vec![PromptFragment::developer_policy( - "Prefer short answers unless the user asks for detail.", - )] + vec![PromptFragment::developer_policy( + "Prefer short answers unless the user asks for detail.", + )] + }) } } @@ -35,17 +37,19 @@ impl ContextContributor for StyleContributor { struct UsageContributor; impl ContextContributor for UsageContributor { - fn contribute( - &self, - session_store: &ExtensionData, - thread_store: &ExtensionData, - ) -> Vec { - contribution_counts(session_store).record_usage(); - contribution_counts(thread_store).record_usage(); + fn contribute<'a>( + &'a self, + session_store: &'a ExtensionData, + thread_store: &'a ExtensionData, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + contribution_counts(session_store).record_usage(); + contribution_counts(thread_store).record_usage(); - vec![PromptFragment::developer_capability( - "This extension can contribute more than one prompt fragment.", - )] + vec![PromptFragment::developer_capability( + "This extension can contribute more than one prompt fragment.", + )] + }) } } diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 18ca7b0299..f3d1375ac8 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -1,42 +1,127 @@ use std::future::Future; +use std::sync::Arc; -use codex_protocol::ThreadId; use codex_protocol::items::TurnItem; -use codex_tool_api::ToolBundle; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::TokenUsageInfo; use crate::ExtensionData; mod prompt; +mod thread_lifecycle; +mod tools; +mod turn_lifecycle; pub use prompt::PromptFragment; pub use prompt::PromptSlot; - -/// Contributor that receives the live thread id and host-owned thread-start -/// input before later contributors read from extension stores. -pub trait ThreadStartContributor: Send + Sync { - fn contribute( - &self, - thread_id: ThreadId, - input: &C, - session_store: &ExtensionData, - thread_store: &ExtensionData, - ); -} +pub use thread_lifecycle::ThreadResumeInput; +pub use thread_lifecycle::ThreadStartInput; +pub use thread_lifecycle::ThreadStopInput; +pub use tools::ExtensionToolExecutor; +pub use tools::ExtensionToolFuture; +pub use tools::ExtensionToolOutput; +pub use turn_lifecycle::TurnAbortInput; +pub use turn_lifecycle::TurnStartInput; +pub use turn_lifecycle::TurnStopInput; /// Extension contribution that adds prompt fragments during prompt assembly. pub trait ContextContributor: Send + Sync { - fn contribute( + fn contribute<'a>( + &'a self, + session_store: &'a ExtensionData, + thread_store: &'a ExtensionData, + ) -> std::pin::Pin> + Send + 'a>>; +} + +/// Contributor for host-owned thread lifecycle gates. +/// +/// Implementations should use these callbacks to seed, rehydrate, or flush +/// extension-private thread state. Heavy dependencies belong on the extension +/// value created by the host, not in these inputs. +pub trait ThreadLifecycleContributor: Send + Sync { + /// Called after thread-scoped extension stores are created, before later + /// contributors can read from them. + fn on_thread_start(&self, _input: ThreadStartInput<'_, C>) {} + + /// Called after the host constructs a runtime from persisted history. + fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {} + + /// Called before the host drops the thread runtime and thread-scoped store. + fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {} +} + +/// Contributor for host-owned turn lifecycle gates. +/// +/// Implementations should use these callbacks to seed, observe, or clear +/// extension-private turn state. The host exposes stable identifiers and +/// extension stores instead of core runtime objects. +pub trait TurnLifecycleContributor: Send + Sync { + /// Called after turn-scoped extension stores are created, before the task + /// for the turn starts running. + fn on_turn_start(&self, _input: TurnStartInput<'_>) {} + + /// Called before the host drops the completed turn runtime and turn store. + fn on_turn_stop(&self, _input: TurnStopInput<'_>) {} + + /// Called after the host aborts a running turn. + fn on_turn_abort(&self, _input: TurnAbortInput<'_>) {} +} + +/// Contributor for host-owned configuration changes. +/// +/// Implementations should treat the supplied values as immutable before/after +/// snapshots of the effective thread configuration. +pub trait ConfigContributor: Send + Sync { + /// Called after the host commits a changed thread configuration. + fn on_config_changed( &self, - session_store: &ExtensionData, - thread_store: &ExtensionData, - ) -> Vec; + _session_store: &ExtensionData, + _thread_store: &ExtensionData, + _previous_config: &C, + _new_config: &C, + ) { + } +} + +/// Contributor for token usage checkpoints reported by the model provider. +/// +/// Implementations should keep this callback cheap. The host calls it after +/// updating cached token usage and before emitting the corresponding client +/// token-count notification. +pub trait TokenUsageContributor: Send + Sync { + /// Called each time the host records token usage from a model response. + fn on_token_usage( + &self, + _session_store: &ExtensionData, + _thread_store: &ExtensionData, + _turn_store: &ExtensionData, + _token_usage: &TokenUsageInfo, + ) { + } } /// Extension contribution that exposes native tools owned by a feature. pub trait ToolContributor: Send + Sync { /// Returns the native tools visible for the supplied extension stores. - fn tools(&self, session_store: &ExtensionData, thread_store: &ExtensionData) - -> Vec; + fn tools( + &self, + session_store: &ExtensionData, + thread_store: &ExtensionData, + ) -> Vec>; +} + +/// Future returned by one claimed approval-review contribution. +pub type ApprovalReviewFuture<'a> = + std::pin::Pin + Send + 'a>>; + +/// Extension contribution that can claim rendered approval-review prompts. +pub trait ApprovalReviewContributor: Send + Sync { + fn contribute<'a>( + &'a self, + session_store: &'a ExtensionData, + thread_store: &'a ExtensionData, + prompt: &'a str, + ) -> Option>; } /// Future returned by one ordered turn-item contribution. @@ -56,15 +141,3 @@ pub trait TurnItemContributor: Send + Sync { item: &'a mut TurnItem, ) -> TurnItemContributionFuture<'a>; } - -// TODO: WIP (do not consider) -/// Extension contribution that can claim approval requests for a runtime context. -/// (ideally we can replace it by a session lifecycle thing or a request contributor?) -pub trait ApprovalInterceptorContributor: Send + Sync { - /// Returns whether this contributor should intercept approvals in `context`. - fn intercepts_approvals( - &self, - thread_store: &ExtensionData, - turn_store: &ExtensionData, - ) -> bool; -} diff --git a/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs b/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs new file mode 100644 index 0000000000..d50f5a1e2f --- /dev/null +++ b/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs @@ -0,0 +1,27 @@ +use crate::ExtensionData; + +/// Input supplied when the host starts a runtime for a thread. +pub struct ThreadStartInput<'a, C> { + /// Host configuration visible at thread start. + pub config: &'a C, + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, +} + +/// Input supplied when the host resumes an existing thread. +pub struct ThreadResumeInput<'a> { + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, +} + +/// Input supplied when the host stops a thread runtime. +pub struct ThreadStopInput<'a> { + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, +} diff --git a/codex-rs/ext/extension-api/src/contributors/tools.rs b/codex-rs/ext/extension-api/src/contributors/tools.rs new file mode 100644 index 0000000000..41d08986e9 --- /dev/null +++ b/codex-rs/ext/extension-api/src/contributors/tools.rs @@ -0,0 +1,32 @@ +use std::future::Future; +use std::pin::Pin; + +use codex_tools::FunctionCallError; +use codex_tools::JsonToolOutput; +use codex_tools::ToolCall; +use codex_tools::ToolName; +use codex_tools::ToolSpec; + +/// Model-facing output returned by extension-owned tools. +pub type ExtensionToolOutput = JsonToolOutput; + +/// Future returned by extension-owned tool execution. +pub type ExtensionToolFuture<'a> = + Pin> + Send + 'a>>; + +/// Object-safe runtime contract for extension-owned model-visible tools. +/// +/// Implementations keep an extension tool's model-visible spec attached to the +/// executable runtime that handles calls for that tool. +pub trait ExtensionToolExecutor: Send + Sync { + /// The concrete tool name handled by this extension runtime. + fn tool_name(&self) -> ToolName; + + /// The model-visible spec for this extension tool. + fn spec(&self) -> Option { + None + } + + /// Execute one extension tool invocation. + fn handle(&self, call: ToolCall) -> ExtensionToolFuture<'_>; +} diff --git a/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs b/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs new file mode 100644 index 0000000000..0b53183e28 --- /dev/null +++ b/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs @@ -0,0 +1,35 @@ +use codex_protocol::protocol::TurnAbortReason; + +use crate::ExtensionData; + +/// Input supplied when the host starts a turn. +pub struct TurnStartInput<'a> { + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, + /// Store scoped to this turn runtime. + pub turn_store: &'a ExtensionData, +} + +/// Input supplied when the host completes a turn. +pub struct TurnStopInput<'a> { + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, + /// Store scoped to this turn runtime. + pub turn_store: &'a ExtensionData, +} + +/// Input supplied when the host aborts a turn. +pub struct TurnAbortInput<'a> { + /// Reason the host aborted the turn. + pub reason: TurnAbortReason, + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, + /// Store scoped to this turn runtime. + pub turn_store: &'a ExtensionData, +} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 3c9ee38a02..ccfa2520e4 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -5,20 +5,35 @@ mod state; pub use capabilities::AgentSpawnFuture; pub use capabilities::AgentSpawner; -pub use codex_tool_api::FunctionToolSpec; -pub use codex_tool_api::ToolBundle; -pub use codex_tool_api::ToolCall; -pub use codex_tool_api::ToolError; -pub use codex_tool_api::ToolExecutor; -pub use codex_tool_api::ToolFuture; -pub use contributors::ApprovalInterceptorContributor; +pub use codex_tools::FunctionCallError; +pub use codex_tools::JsonToolOutput; +pub use codex_tools::ResponsesApiTool; +pub use codex_tools::ToolCall; +pub use codex_tools::ToolName; +pub use codex_tools::ToolPayload; +pub use codex_tools::ToolSpec; +pub use codex_tools::parse_tool_input_schema; +pub use contributors::ApprovalReviewContributor; +pub use contributors::ApprovalReviewFuture; +pub use contributors::ConfigContributor; pub use contributors::ContextContributor; +pub use contributors::ExtensionToolExecutor; +pub use contributors::ExtensionToolFuture; +pub use contributors::ExtensionToolOutput; pub use contributors::PromptFragment; pub use contributors::PromptSlot; -pub use contributors::ThreadStartContributor; +pub use contributors::ThreadLifecycleContributor; +pub use contributors::ThreadResumeInput; +pub use contributors::ThreadStartInput; +pub use contributors::ThreadStopInput; +pub use contributors::TokenUsageContributor; pub use contributors::ToolContributor; +pub use contributors::TurnAbortInput; pub use contributors::TurnItemContributionFuture; pub use contributors::TurnItemContributor; +pub use contributors::TurnLifecycleContributor; +pub use contributors::TurnStartInput; +pub use contributors::TurnStopInput; pub use registry::ExtensionRegistry; pub use registry::ExtensionRegistryBuilder; pub use registry::empty_extension_registry; diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index e4039ec29c..8193f5c1d1 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -1,25 +1,36 @@ use std::sync::Arc; -use crate::ApprovalInterceptorContributor; +use crate::ApprovalReviewContributor; +use crate::ApprovalReviewFuture; +use crate::ConfigContributor; use crate::ContextContributor; -use crate::ThreadStartContributor; +use crate::ExtensionData; +use crate::ThreadLifecycleContributor; +use crate::TokenUsageContributor; use crate::ToolContributor; use crate::TurnItemContributor; +use crate::TurnLifecycleContributor; /// Mutable registry used while hosts register typed runtime contributions. pub struct ExtensionRegistryBuilder { - thread_start_contributors: Vec>>, + thread_lifecycle_contributors: Vec>>, + turn_lifecycle_contributors: Vec>, + config_contributors: Vec>>, + token_usage_contributors: Vec>, context_contributors: Vec>, tool_contributors: Vec>, turn_item_contributors: Vec>, - approval_interceptor_contributors: Vec>, + approval_review_contributors: Vec>, } impl Default for ExtensionRegistryBuilder { fn default() -> Self { Self { - thread_start_contributors: Vec::new(), - approval_interceptor_contributors: Vec::new(), + thread_lifecycle_contributors: Vec::new(), + turn_lifecycle_contributors: Vec::new(), + config_contributors: Vec::new(), + token_usage_contributors: Vec::new(), + approval_review_contributors: Vec::new(), context_contributors: Vec::new(), tool_contributors: Vec::new(), turn_item_contributors: Vec::new(), @@ -33,17 +44,32 @@ impl ExtensionRegistryBuilder { Self::default() } - /// Registers one approval interceptor contributor. - pub fn approval_interceptor_contributor( - &mut self, - contributor: Arc, - ) { - self.approval_interceptor_contributors.push(contributor); + /// Registers one approval-review contributor. + pub fn approval_review_contributor(&mut self, contributor: Arc) { + self.approval_review_contributors.push(contributor); } - /// Registers one thread-start contributor. - pub fn thread_start_contributor(&mut self, contributor: Arc>) { - self.thread_start_contributors.push(contributor); + /// Registers one thread-lifecycle contributor. + pub fn thread_lifecycle_contributor( + &mut self, + contributor: Arc>, + ) { + self.thread_lifecycle_contributors.push(contributor); + } + + /// Registers one turn-lifecycle contributor. + pub fn turn_lifecycle_contributor(&mut self, contributor: Arc) { + self.turn_lifecycle_contributors.push(contributor); + } + + /// Registers one config contributor. + pub fn config_contributor(&mut self, contributor: Arc>) { + self.config_contributors.push(contributor); + } + + /// Registers one token-usage contributor. + pub fn token_usage_contributor(&mut self, contributor: Arc) { + self.token_usage_contributors.push(contributor); } /// Registers one prompt contributor. @@ -64,8 +90,11 @@ impl ExtensionRegistryBuilder { /// Finishes construction and returns the immutable registry. pub fn build(self) -> ExtensionRegistry { ExtensionRegistry { - thread_start_contributors: self.thread_start_contributors, - approval_interceptor_contributors: self.approval_interceptor_contributors, + thread_lifecycle_contributors: self.thread_lifecycle_contributors, + turn_lifecycle_contributors: self.turn_lifecycle_contributors, + config_contributors: self.config_contributors, + token_usage_contributors: self.token_usage_contributors, + approval_review_contributors: self.approval_review_contributors, context_contributors: self.context_contributors, tool_contributors: self.tool_contributors, turn_item_contributors: self.turn_item_contributors, @@ -75,22 +104,48 @@ impl ExtensionRegistryBuilder { /// Immutable typed registry produced after extensions are installed. pub struct ExtensionRegistry { - thread_start_contributors: Vec>>, + thread_lifecycle_contributors: Vec>>, + turn_lifecycle_contributors: Vec>, + config_contributors: Vec>>, + token_usage_contributors: Vec>, context_contributors: Vec>, tool_contributors: Vec>, turn_item_contributors: Vec>, - approval_interceptor_contributors: Vec>, + approval_review_contributors: Vec>, } impl ExtensionRegistry { - /// Returns the registered thread-start contributors. - pub fn thread_start_contributors(&self) -> &[Arc>] { - &self.thread_start_contributors + /// Returns the registered thread-lifecycle contributors. + pub fn thread_lifecycle_contributors(&self) -> &[Arc>] { + &self.thread_lifecycle_contributors } - /// Returns the registered approval interceptor contributors. - pub fn approval_interceptor_contributors(&self) -> &[Arc] { - &self.approval_interceptor_contributors + /// Returns the registered turn-lifecycle contributors. + pub fn turn_lifecycle_contributors(&self) -> &[Arc] { + &self.turn_lifecycle_contributors + } + + /// Returns the registered config contributors. + pub fn config_contributors(&self) -> &[Arc>] { + &self.config_contributors + } + + /// Returns the registered token-usage contributors. + pub fn token_usage_contributors(&self) -> &[Arc] { + &self.token_usage_contributors + } + + /// Claims the first rendered approval-review prompt accepted by an + /// installed contributor. + pub fn approval_review<'a>( + &'a self, + session_store: &'a ExtensionData, + thread_store: &'a ExtensionData, + prompt: &'a str, + ) -> Option> { + self.approval_review_contributors + .iter() + .find_map(|contributor| contributor.contribute(session_store, thread_store, prompt)) } /// Returns the registered prompt contributors. diff --git a/codex-rs/ext/extension-api/src/state.rs b/codex-rs/ext/extension-api/src/state.rs index 2fc9a8725f..aab37f5059 100644 --- a/codex-rs/ext/extension-api/src/state.rs +++ b/codex-rs/ext/extension-api/src/state.rs @@ -8,15 +8,24 @@ use std::sync::PoisonError; type ErasedData = Arc; /// Typed extension-owned data attached to one host object. -#[derive(Default, Debug)] +#[derive(Debug)] pub struct ExtensionData { + level_id: String, entries: Mutex>, } impl ExtensionData { - /// Creates an empty attachment map. - pub fn new() -> Self { - Self::default() + /// Creates an empty attachment map for one host-owned scope. + pub fn new(level_id: impl Into) -> Self { + Self { + level_id: level_id.into(), + entries: Mutex::new(HashMap::new()), + } + } + + /// Returns the host identity for the scope this data is attached to. + pub fn level_id(&self) -> &str { + &self.level_id } /// Returns the attached value of type `T`, if one exists. diff --git a/codex-rs/ext/git-attribution/Cargo.toml b/codex-rs/ext/git-attribution/Cargo.toml index 500ce47aec..edf8c07196 100644 --- a/codex-rs/ext/git-attribution/Cargo.toml +++ b/codex-rs/ext/git-attribution/Cargo.toml @@ -16,7 +16,6 @@ workspace = true codex-core = { workspace = true } codex-extension-api = { workspace = true } codex-features = { workspace = true } -codex-protocol = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/ext/git-attribution/src/lib.rs b/codex-rs/ext/git-attribution/src/lib.rs index eae906fa56..03655296da 100644 --- a/codex-rs/ext/git-attribution/src/lib.rs +++ b/codex-rs/ext/git-attribution/src/lib.rs @@ -5,9 +5,9 @@ use codex_extension_api::ContextContributor; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::PromptFragment; -use codex_extension_api::ThreadStartContributor; +use codex_extension_api::ThreadLifecycleContributor; +use codex_extension_api::ThreadStartInput; use codex_features::Feature; -use codex_protocol::ThreadId; const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex "; @@ -16,21 +16,23 @@ const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex "; pub struct GitAttributionExtension; impl ContextContributor for GitAttributionExtension { - fn contribute( - &self, - _session_store: &ExtensionData, - thread_store: &ExtensionData, - ) -> Vec { - let Some(config_store) = thread_store.get::() else { - return Vec::new(); - }; - if !config_store.enabled { - return Vec::new(); - } - build_instruction(config_store.prompt.as_deref()) - .map(PromptFragment::developer_policy) - .into_iter() - .collect() + fn contribute<'a>( + &'a self, + _session_store: &'a ExtensionData, + thread_store: &'a ExtensionData, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + let Some(config_store) = thread_store.get::() else { + return Vec::new(); + }; + if !config_store.enabled { + return Vec::new(); + } + build_instruction(config_store.prompt.as_deref()) + .map(PromptFragment::developer_policy) + .into_iter() + .collect() + }) } } @@ -40,17 +42,11 @@ struct GitAttributionConfig { prompt: Option, } -impl ThreadStartContributor for GitAttributionExtension { - fn contribute( - &self, - _thread_id: ThreadId, - config: &Config, - _session_store: &ExtensionData, - thread_store: &ExtensionData, - ) { - thread_store.insert(GitAttributionConfig { - enabled: config.features.enabled(Feature::CodexGitCommit), - prompt: config.commit_attribution.clone(), +impl ThreadLifecycleContributor for GitAttributionExtension { + fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + input.thread_store.insert(GitAttributionConfig { + enabled: input.config.features.enabled(Feature::CodexGitCommit), + prompt: input.config.commit_attribution.clone(), }); } } @@ -58,7 +54,7 @@ impl ThreadStartContributor for GitAttributionExtension { /// Installs the git-attribution contributors into the extension registry. pub fn install(registry: &mut ExtensionRegistryBuilder) { let extension = Arc::new(GitAttributionExtension); - registry.thread_start_contributor(extension.clone()); + registry.thread_lifecycle_contributor(extension.clone()); registry.prompt_contributor(extension); } diff --git a/codex-rs/ext/guardian/src/lib.rs b/codex-rs/ext/guardian/src/lib.rs index d8c9834d60..809a381471 100644 --- a/codex-rs/ext/guardian/src/lib.rs +++ b/codex-rs/ext/guardian/src/lib.rs @@ -3,9 +3,9 @@ use std::sync::Arc; use codex_core::config::Config; use codex_extension_api::AgentSpawnFuture; use codex_extension_api::AgentSpawner; -use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::ThreadStartContributor; +use codex_extension_api::ThreadLifecycleContributor; +use codex_extension_api::ThreadStartInput; use codex_protocol::ThreadId; /// Guardian extension dependencies supplied by the host at construction time. @@ -47,19 +47,16 @@ impl GuardianThreadContext { } } -impl ThreadStartContributor for GuardianExtension +impl ThreadLifecycleContributor for GuardianExtension where S: Send + Sync, { - fn contribute( - &self, - thread_id: ThreadId, - _input: &Config, - _session_store: &ExtensionData, - thread_store: &ExtensionData, - ) { - thread_store.insert(GuardianThreadContext { - forked_from_thread_id: thread_id, + fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + let Ok(forked_from_thread_id) = ThreadId::from_string(input.thread_store.level_id()) else { + return; + }; + input.thread_store.insert(GuardianThreadContext { + forked_from_thread_id, }); } } @@ -69,5 +66,5 @@ pub fn install(registry: &mut ExtensionRegistryBuilder, agent_spawner where S: Send + Sync + 'static, { - registry.thread_start_contributor(Arc::new(GuardianExtension::new(agent_spawner))); + registry.thread_lifecycle_contributor(Arc::new(GuardianExtension::new(agent_spawner))); } diff --git a/codex-rs/ext/memories/BUILD.bazel b/codex-rs/ext/memories/BUILD.bazel new file mode 100644 index 0000000000..5da7a952ab --- /dev/null +++ b/codex-rs/ext/memories/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "memories", + crate_name = "codex_memories_extension", +) diff --git a/codex-rs/ext/memories/Cargo.toml b/codex-rs/ext/memories/Cargo.toml new file mode 100644 index 0000000000..542eaa6bc5 --- /dev/null +++ b/codex-rs/ext/memories/Cargo.toml @@ -0,0 +1,32 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-memories-extension" +version.workspace = true + +[lib] +name = "codex_memories_extension" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +codex-core = { workspace = true } +codex-extension-api = { workspace = true } +codex-features = { workspace = true } +codex-memories-read = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-output-truncation = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs"] } + +[dev-dependencies] +codex-tools = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["fs", "macros"] } diff --git a/codex-rs/ext/memories/src/backend.rs b/codex-rs/ext/memories/src/backend.rs new file mode 100644 index 0000000000..fa635c8302 --- /dev/null +++ b/codex-rs/ext/memories/src/backend.rs @@ -0,0 +1,158 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::future::Future; + +/// Storage interface behind the memories MCP tools. +/// +/// Implementations should return paths relative to the memory store and enforce +/// their own storage-specific access rules. The local implementation uses the +/// filesystem today; a later implementation can satisfy the same contract from a +/// remote backend. +pub trait MemoriesBackend: Clone + Send + Sync + 'static { + fn list( + &self, + request: ListMemoriesRequest, + ) -> impl Future> + Send; + + fn read( + &self, + request: ReadMemoryRequest, + ) -> impl Future> + Send; + + fn search( + &self, + request: SearchMemoriesRequest, + ) -> impl Future> + Send; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListMemoriesRequest { + pub path: Option, + pub cursor: Option, + pub max_results: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ListMemoriesResponse { + pub path: Option, + pub entries: Vec, + pub next_cursor: Option, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadMemoryRequest { + pub path: String, + pub line_offset: usize, + pub max_lines: Option, + pub max_tokens: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ReadMemoryResponse { + pub path: String, + pub start_line_number: usize, + pub content: String, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchMemoriesRequest { + pub queries: Vec, + pub match_mode: SearchMatchMode, + pub path: Option, + pub cursor: Option, + pub context_lines: usize, + pub case_sensitive: bool, + pub normalized: bool, + pub max_results: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SearchMemoriesResponse { + pub queries: Vec, + pub match_mode: SearchMatchMode, + pub path: Option, + pub matches: Vec, + pub next_cursor: Option, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SearchMatchMode { + Any, + AllOnSameLine, + AllWithinLines { + #[schemars(range(min = 1))] + line_count: usize, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct MemoryEntry { + pub path: String, + pub entry_type: MemoryEntryType, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MemoryEntryType { + File, + Directory, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct MemorySearchMatch { + pub path: String, + pub match_line_number: usize, + pub content_start_line_number: usize, + pub content: String, + pub matched_queries: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum MemoriesBackendError { + #[error("path '{path}' {reason}")] + InvalidPath { path: String, reason: String }, + #[error("cursor '{cursor}' {reason}")] + InvalidCursor { cursor: String, reason: String }, + #[error("path '{path}' was not found")] + NotFound { path: String }, + #[error("line_offset must be a 1-indexed line number")] + InvalidLineOffset, + #[error("max_lines must be a positive integer")] + InvalidMaxLines, + #[error("line_offset exceeds file length")] + LineOffsetExceedsFileLength, + #[error("path '{path}' is not a file")] + NotFile { path: String }, + #[error("queries must not be empty or contain empty strings")] + EmptyQuery, + #[error("all_within_lines.line_count must be a positive integer")] + InvalidMatchWindow, + #[error("I/O error while reading memories: {0}")] + Io(#[from] std::io::Error), +} + +impl MemoriesBackendError { + pub fn invalid_path(path: impl Into, reason: impl Into) -> Self { + Self::InvalidPath { + path: path.into(), + reason: reason.into(), + } + } + + pub fn invalid_cursor(cursor: impl Into, reason: impl Into) -> Self { + Self::InvalidCursor { + cursor: cursor.into(), + reason: reason.into(), + } + } +} diff --git a/codex-rs/ext/memories/src/extension.rs b/codex-rs/ext/memories/src/extension.rs new file mode 100644 index 0000000000..2b49559183 --- /dev/null +++ b/codex-rs/ext/memories/src/extension.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; + +use codex_core::config::Config; +use codex_extension_api::ContextContributor; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::PromptFragment; +use codex_extension_api::ThreadLifecycleContributor; +use codex_extension_api::ThreadStartInput; +use codex_extension_api::ToolContributor; +use codex_features::Feature; +use codex_memories_read::build_memory_tool_developer_instructions; +use codex_utils_absolute_path::AbsolutePathBuf; + +use crate::local::LocalMemoriesBackend; +use crate::tools; + +/// Contributes Codex memory read-path prompt context and memory read tools. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct MemoriesExtension; + +#[derive(Clone, Debug)] +pub(crate) struct MemoriesExtensionConfig { + pub(crate) enabled: bool, + pub(crate) codex_home: AbsolutePathBuf, +} + +impl ContextContributor for MemoriesExtension { + fn contribute<'a>( + &'a self, + _session_store: &'a ExtensionData, + thread_store: &'a ExtensionData, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + let Some(config) = thread_store.get::() else { + return Vec::new(); + }; + if !config.enabled { + return Vec::new(); + } + + build_memory_tool_developer_instructions(&config.codex_home) + .await + .map(PromptFragment::developer_policy) + .into_iter() + .collect() + }) + } +} + +impl ThreadLifecycleContributor for MemoriesExtension { + fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + input.thread_store.insert(MemoriesExtensionConfig { + enabled: input.config.features.enabled(Feature::MemoryTool) + && input.config.memories.use_memories, + codex_home: input.config.codex_home.clone(), + }); + } +} + +impl ToolContributor for MemoriesExtension { + fn tools( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + ) -> Vec> { + let Some(config) = thread_store.get::() else { + return Vec::new(); + }; + if !config.enabled { + return Vec::new(); + } + + tools::memory_tools(LocalMemoriesBackend::from_codex_home(&config.codex_home)) + } +} + +/// Installs the memories extension contributors into the extension registry. +pub fn install(registry: &mut ExtensionRegistryBuilder) { + let extension = Arc::new(MemoriesExtension); + registry.thread_lifecycle_contributor(extension.clone()); + registry.prompt_contributor(extension.clone()); + registry.tool_contributor(extension); +} diff --git a/codex-rs/ext/memories/src/lib.rs b/codex-rs/ext/memories/src/lib.rs new file mode 100644 index 0000000000..4b6f74c571 --- /dev/null +++ b/codex-rs/ext/memories/src/lib.rs @@ -0,0 +1,20 @@ +mod backend; +mod extension; +mod local; +mod schema; +mod tools; + +pub use extension::install; + +pub(crate) const DEFAULT_LIST_MAX_RESULTS: usize = 2_000; +pub(crate) const MAX_LIST_RESULTS: usize = 2_000; +pub(crate) const DEFAULT_SEARCH_MAX_RESULTS: usize = 200; +pub(crate) const MAX_SEARCH_RESULTS: usize = 200; +pub(crate) const DEFAULT_READ_MAX_TOKENS: usize = 20_000; + +pub(crate) const LIST_TOOL_NAME: &str = "memory_list"; +pub(crate) const READ_TOOL_NAME: &str = "memory_read"; +pub(crate) const SEARCH_TOOL_NAME: &str = "memory_search"; + +#[cfg(test)] +mod tests; diff --git a/codex-rs/ext/memories/src/local.rs b/codex-rs/ext/memories/src/local.rs new file mode 100644 index 0000000000..8c663d73fb --- /dev/null +++ b/codex-rs/ext/memories/src/local.rs @@ -0,0 +1,119 @@ +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; + +use crate::backend::ListMemoriesRequest; +use crate::backend::ListMemoriesResponse; +use crate::backend::MemoriesBackend; +use crate::backend::MemoriesBackendError; +use crate::backend::ReadMemoryRequest; +use crate::backend::ReadMemoryResponse; +use crate::backend::SearchMemoriesRequest; +use crate::backend::SearchMemoriesResponse; + +mod list; +mod path; +mod read; +mod search; + +#[derive(Debug, Clone)] +pub(crate) struct LocalMemoriesBackend { + root: PathBuf, +} + +impl LocalMemoriesBackend { + pub(crate) fn from_codex_home(codex_home: &AbsolutePathBuf) -> Self { + Self::from_memory_root(codex_home.join("memories").to_path_buf()) + } + + pub(crate) fn from_memory_root(root: impl Into) -> Self { + Self { root: root.into() } + } + + async fn resolve_scoped_path( + &self, + relative_path: Option<&str>, + ) -> Result { + let Some(relative_path) = relative_path else { + return Ok(self.root.clone()); + }; + let relative = Path::new(relative_path); + if relative.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(MemoriesBackendError::invalid_path( + relative_path, + "must stay within the memories root", + )); + } + if relative.components().any(path::is_hidden_component) { + return Err(MemoriesBackendError::NotFound { + path: relative_path.to_string(), + }); + } + + let components = relative.components().collect::>(); + let mut scoped_path = self.root.clone(); + for (idx, component) in components.iter().enumerate() { + scoped_path.push(component.as_os_str()); + + let Some(metadata) = Self::metadata_or_none(&scoped_path).await? else { + for remaining_component in components.iter().skip(idx + 1) { + scoped_path.push(remaining_component.as_os_str()); + } + return Ok(scoped_path); + }; + + path::reject_symlink( + &path::display_relative_path(&self.root, &scoped_path), + &metadata, + )?; + if idx + 1 < components.len() && !metadata.is_dir() { + return Err(MemoriesBackendError::invalid_path( + relative_path, + "traverses through a non-directory path component", + )); + } + } + + Ok(scoped_path) + } + + async fn metadata_or_none( + path: &Path, + ) -> Result, MemoriesBackendError> { + match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => Ok(Some(metadata)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.into()), + } + } +} + +impl MemoriesBackend for LocalMemoriesBackend { + async fn list( + &self, + request: ListMemoriesRequest, + ) -> Result { + list::list(self, request).await + } + + async fn read( + &self, + request: ReadMemoryRequest, + ) -> Result { + read::read(self, request).await + } + + async fn search( + &self, + request: SearchMemoriesRequest, + ) -> Result { + search::search(self, request).await + } +} diff --git a/codex-rs/ext/memories/src/local/list.rs b/codex-rs/ext/memories/src/local/list.rs new file mode 100644 index 0000000000..60915aa1f9 --- /dev/null +++ b/codex-rs/ext/memories/src/local/list.rs @@ -0,0 +1,83 @@ +use crate::MAX_LIST_RESULTS; +use crate::backend::ListMemoriesRequest; +use crate::backend::ListMemoriesResponse; +use crate::backend::MemoriesBackendError; +use crate::backend::MemoryEntry; +use crate::backend::MemoryEntryType; + +use super::LocalMemoriesBackend; +use super::path::display_relative_path; +use super::path::is_hidden_path; +use super::path::read_sorted_dir_paths; +use super::path::reject_symlink; + +pub(super) async fn list( + backend: &LocalMemoriesBackend, + request: ListMemoriesRequest, +) -> Result { + let max_results = request.max_results.min(MAX_LIST_RESULTS); + let start = backend.resolve_scoped_path(request.path.as_deref()).await?; + let start_index = match request.cursor.as_deref() { + Some(cursor) => cursor.parse::().map_err(|_| { + MemoriesBackendError::invalid_cursor(cursor, "must be a non-negative integer") + })?, + None => 0, + }; + let Some(metadata) = LocalMemoriesBackend::metadata_or_none(&start).await? else { + return Err(MemoriesBackendError::NotFound { + path: request.path.unwrap_or_default(), + }); + }; + reject_symlink(&display_relative_path(&backend.root, &start), &metadata)?; + + let mut entries = if metadata.is_file() { + vec![MemoryEntry { + path: display_relative_path(&backend.root, &start), + entry_type: MemoryEntryType::File, + }] + } else if metadata.is_dir() { + let mut entries = Vec::new(); + for path in read_sorted_dir_paths(&start).await? { + if is_hidden_path(&path) { + continue; + } + let Some(metadata) = LocalMemoriesBackend::metadata_or_none(&path).await? else { + continue; + }; + if metadata.file_type().is_symlink() { + continue; + } + + let entry_type = if metadata.is_dir() { + MemoryEntryType::Directory + } else if metadata.is_file() { + MemoryEntryType::File + } else { + continue; + }; + entries.push(MemoryEntry { + path: display_relative_path(&backend.root, &path), + entry_type, + }); + } + entries + } else { + Vec::new() + }; + if start_index > entries.len() { + return Err(MemoriesBackendError::invalid_cursor( + start_index.to_string(), + "exceeds result count", + )); + } + + let end_index = start_index.saturating_add(max_results).min(entries.len()); + let next_cursor = (end_index < entries.len()).then(|| end_index.to_string()); + let truncated = next_cursor.is_some(); + Ok(ListMemoriesResponse { + path: request.path, + entries: entries.drain(start_index..end_index).collect(), + next_cursor, + truncated, + }) +} diff --git a/codex-rs/ext/memories/src/local/path.rs b/codex-rs/ext/memories/src/local/path.rs new file mode 100644 index 0000000000..2052967f68 --- /dev/null +++ b/codex-rs/ext/memories/src/local/path.rs @@ -0,0 +1,56 @@ +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use crate::backend::MemoriesBackendError; + +pub(super) async fn read_sorted_dir_paths( + dir_path: &Path, +) -> Result, MemoriesBackendError> { + let mut dir = match tokio::fs::read_dir(dir_path).await { + Ok(dir) => dir, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err.into()), + }; + let mut paths = Vec::new(); + while let Some(entry) = dir.next_entry().await? { + paths.push(entry.path()); + } + paths.sort(); + Ok(paths) +} + +pub(super) fn reject_symlink( + path: &str, + metadata: &std::fs::Metadata, +) -> Result<(), MemoriesBackendError> { + if metadata.file_type().is_symlink() { + return Err(MemoriesBackendError::invalid_path( + path, + "must not be a symlink", + )); + } + Ok(()) +} + +pub(super) fn is_hidden_component(component: Component<'_>) -> bool { + matches!( + component, + Component::Normal(name) if name.to_string_lossy().starts_with('.') + ) +} + +pub(super) fn is_hidden_path(path: &Path) -> bool { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with('.')) +} + +pub(super) fn display_relative_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .filter(|component| !component.is_empty()) + .collect::>() + .join("/") +} diff --git a/codex-rs/ext/memories/src/local/read.rs b/codex-rs/ext/memories/src/local/read.rs new file mode 100644 index 0000000000..d2e22bd1a8 --- /dev/null +++ b/codex-rs/ext/memories/src/local/read.rs @@ -0,0 +1,90 @@ +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; + +use crate::DEFAULT_READ_MAX_TOKENS; +use crate::backend::MemoriesBackendError; +use crate::backend::ReadMemoryRequest; +use crate::backend::ReadMemoryResponse; + +use super::LocalMemoriesBackend; +use super::path::reject_symlink; + +pub(super) async fn read( + backend: &LocalMemoriesBackend, + request: ReadMemoryRequest, +) -> Result { + if request.line_offset == 0 { + return Err(MemoriesBackendError::InvalidLineOffset); + } + if request.max_lines == Some(0) { + return Err(MemoriesBackendError::InvalidMaxLines); + } + + let path = backend + .resolve_scoped_path(Some(request.path.as_str())) + .await?; + let Some(metadata) = LocalMemoriesBackend::metadata_or_none(&path).await? else { + return Err(MemoriesBackendError::NotFound { path: request.path }); + }; + reject_symlink(&request.path, &metadata)?; + if !metadata.is_file() { + return Err(MemoriesBackendError::NotFile { path: request.path }); + } + + let original_content = tokio::fs::read_to_string(&path).await?; + let start_byte = line_start_byte_offset(&original_content, request.line_offset)?; + let end_byte = line_end_byte_offset(&original_content, start_byte, request.max_lines); + let content_from_offset = &original_content[start_byte..end_byte]; + let max_tokens = if request.max_tokens == 0 { + DEFAULT_READ_MAX_TOKENS + } else { + request.max_tokens + }; + let content = truncate_text(content_from_offset, TruncationPolicy::Tokens(max_tokens)); + let truncated = end_byte < original_content.len() || content != content_from_offset; + Ok(ReadMemoryResponse { + path: request.path, + start_line_number: request.line_offset, + content, + truncated, + }) +} + +fn line_start_byte_offset( + content: &str, + line_offset: usize, +) -> Result { + if line_offset == 1 { + return Ok(0); + } + + let mut current_line = 1; + for (idx, ch) in content.char_indices() { + if ch == '\n' { + current_line += 1; + if current_line == line_offset { + return Ok(idx + 1); + } + } + } + + Err(MemoriesBackendError::LineOffsetExceedsFileLength) +} + +fn line_end_byte_offset(content: &str, start_byte: usize, max_lines: Option) -> usize { + let Some(max_lines) = max_lines else { + return content.len(); + }; + + let mut lines_seen = 1; + for (relative_idx, ch) in content[start_byte..].char_indices() { + if ch == '\n' { + if lines_seen == max_lines { + return start_byte + relative_idx + 1; + } + lines_seen += 1; + } + } + + content.len() +} diff --git a/codex-rs/ext/memories/src/local/search.rs b/codex-rs/ext/memories/src/local/search.rs new file mode 100644 index 0000000000..85551fd88a --- /dev/null +++ b/codex-rs/ext/memories/src/local/search.rs @@ -0,0 +1,336 @@ +use std::borrow::Cow; +use std::path::Path; + +use crate::MAX_SEARCH_RESULTS; +use crate::backend::MemoriesBackendError; +use crate::backend::MemorySearchMatch; +use crate::backend::SearchMatchMode; +use crate::backend::SearchMemoriesRequest; +use crate::backend::SearchMemoriesResponse; + +use super::LocalMemoriesBackend; +use super::path::display_relative_path; +use super::path::is_hidden_path; +use super::path::read_sorted_dir_paths; +use super::path::reject_symlink; + +pub(super) async fn search( + backend: &LocalMemoriesBackend, + request: SearchMemoriesRequest, +) -> Result { + let queries = request + .queries + .iter() + .map(|query| query.trim().to_string()) + .collect::>(); + if queries.is_empty() || queries.iter().any(std::string::String::is_empty) { + return Err(MemoriesBackendError::EmptyQuery); + } + if matches!( + request.match_mode, + SearchMatchMode::AllWithinLines { line_count: 0 } + ) { + return Err(MemoriesBackendError::InvalidMatchWindow); + } + + let max_results = request.max_results.min(MAX_SEARCH_RESULTS); + let start = backend.resolve_scoped_path(request.path.as_deref()).await?; + let start_index = match request.cursor.as_deref() { + Some(cursor) => cursor.parse::().map_err(|_| { + MemoriesBackendError::invalid_cursor(cursor, "must be a non-negative integer") + })?, + None => 0, + }; + let Some(metadata) = LocalMemoriesBackend::metadata_or_none(&start).await? else { + return Err(MemoriesBackendError::NotFound { + path: request.path.unwrap_or_default(), + }); + }; + reject_symlink(&display_relative_path(&backend.root, &start), &metadata)?; + + let matcher = SearchMatcher::new( + queries.clone(), + request.match_mode.clone(), + request.case_sensitive, + request.normalized, + )?; + let mut matches = Vec::new(); + search_entries( + &backend.root, + &start, + &metadata, + &matcher, + request.context_lines, + &mut matches, + ) + .await?; + matches.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then(left.match_line_number.cmp(&right.match_line_number)) + }); + if start_index > matches.len() { + return Err(MemoriesBackendError::invalid_cursor( + start_index.to_string(), + "exceeds result count", + )); + } + let end_index = start_index.saturating_add(max_results).min(matches.len()); + let next_cursor = (end_index < matches.len()).then(|| end_index.to_string()); + let truncated = next_cursor.is_some(); + Ok(SearchMemoriesResponse { + queries, + match_mode: request.match_mode, + path: request.path, + matches: matches.drain(start_index..end_index).collect(), + next_cursor, + truncated, + }) +} + +async fn search_entries( + root: &Path, + current: &Path, + current_metadata: &std::fs::Metadata, + matcher: &SearchMatcher, + context_lines: usize, + matches: &mut Vec, +) -> Result<(), MemoriesBackendError> { + if current_metadata.is_file() { + search_file(root, current, matcher, context_lines, matches).await?; + return Ok(()); + } + if !current_metadata.is_dir() { + return Ok(()); + } + + let mut pending = vec![current.to_path_buf()]; + while let Some(dir_path) = pending.pop() { + for path in read_sorted_dir_paths(&dir_path).await? { + if is_hidden_path(&path) { + continue; + } + let Some(metadata) = LocalMemoriesBackend::metadata_or_none(&path).await? else { + continue; + }; + if metadata.file_type().is_symlink() { + continue; + } + if metadata.is_dir() { + pending.push(path); + } else if metadata.is_file() { + search_file(root, &path, matcher, context_lines, matches).await?; + } + } + } + + Ok(()) +} + +async fn search_file( + root: &Path, + path: &Path, + matcher: &SearchMatcher, + context_lines: usize, + matches: &mut Vec, +) -> Result<(), MemoriesBackendError> { + let content = match tokio::fs::read_to_string(path).await { + Ok(content) => content, + Err(err) if err.kind() == std::io::ErrorKind::InvalidData => return Ok(()), + Err(err) => return Err(err.into()), + }; + let lines = content.lines().collect::>(); + let line_matches = lines + .iter() + .map(|line| matcher.matched_query_flags(line)) + .collect::>(); + match &matcher.match_mode { + SearchMatchMode::Any => { + for (idx, matched_query_flags) in line_matches.iter().enumerate() { + if matched_query_flags.iter().any(|matched| *matched) { + matches.push(build_search_match( + root, + path, + &lines, + idx, + idx, + context_lines, + matcher.matched_queries(matched_query_flags), + )); + } + } + } + SearchMatchMode::AllOnSameLine => { + for (idx, matched_query_flags) in line_matches.iter().enumerate() { + if matched_query_flags.iter().all(|matched| *matched) { + matches.push(build_search_match( + root, + path, + &lines, + idx, + idx, + context_lines, + matcher.matched_queries(matched_query_flags), + )); + } + } + } + SearchMatchMode::AllWithinLines { line_count } => { + let mut windows = Vec::new(); + for start_index in 0..lines.len() { + if !line_matches[start_index].iter().any(|matched| *matched) { + continue; + } + let last_allowed_index = start_index + .saturating_add(line_count.saturating_sub(1)) + .min(lines.len().saturating_sub(1)); + let mut matched_query_flags = vec![false; matcher.queries.len()]; + for (end_index, line_match_flags) in line_matches + .iter() + .enumerate() + .take(last_allowed_index + 1) + .skip(start_index) + { + for (idx, matched) in line_match_flags.iter().enumerate() { + matched_query_flags[idx] |= matched; + } + if matched_query_flags.iter().all(|matched| *matched) { + windows.push((start_index, end_index, matched_query_flags)); + break; + } + } + } + for (idx, (start_index, end_index, matched_query_flags)) in windows.iter().enumerate() { + let strictly_contains_another_window = windows.iter().enumerate().any( + |(other_idx, (other_start_index, other_end_index, _))| { + idx != other_idx + && start_index <= other_start_index + && end_index >= other_end_index + && (start_index != other_start_index || end_index != other_end_index) + }, + ); + if strictly_contains_another_window { + continue; + } + matches.push(build_search_match( + root, + path, + &lines, + *start_index, + *end_index, + context_lines, + matcher.matched_queries(matched_query_flags), + )); + } + } + } + Ok(()) +} + +fn build_search_match( + root: &Path, + path: &Path, + lines: &[&str], + match_start_index: usize, + match_end_index: usize, + context_lines: usize, + matched_queries: Vec, +) -> MemorySearchMatch { + let content_start_index = match_start_index.saturating_sub(context_lines); + let content_end_index = match_end_index + .saturating_add(context_lines) + .saturating_add(1) + .min(lines.len()); + MemorySearchMatch { + path: display_relative_path(root, path), + match_line_number: match_start_index + 1, + content_start_line_number: content_start_index + 1, + content: lines[content_start_index..content_end_index].join("\n"), + matched_queries, + } +} + +struct SearchMatcher { + queries: Vec, + prepared_queries: Vec, + comparison: SearchComparison, + match_mode: SearchMatchMode, +} + +impl SearchMatcher { + fn new( + queries: Vec, + match_mode: SearchMatchMode, + case_sensitive: bool, + normalized: bool, + ) -> Result { + let comparison = SearchComparison::new(case_sensitive, normalized); + let prepared_queries = queries + .iter() + .map(|query| comparison.prepare(query)) + .map(Cow::into_owned) + .collect::>(); + if prepared_queries.iter().any(std::string::String::is_empty) { + return Err(MemoriesBackendError::EmptyQuery); + } + Ok(Self { + queries, + prepared_queries, + comparison, + match_mode, + }) + } + + fn matched_query_flags(&self, line: &str) -> Vec { + let line = self.comparison.prepare(line); + self.prepared_queries + .iter() + .map(|query| line.as_ref().contains(query)) + .collect() + } + + fn matched_queries(&self, matched_query_flags: &[bool]) -> Vec { + self.queries + .iter() + .zip(matched_query_flags) + .filter_map(|(query, matched)| matched.then_some(query.clone())) + .collect() + } +} + +#[derive(Clone, Copy)] +struct SearchComparison { + case_sensitive: bool, + normalized: bool, +} + +impl SearchComparison { + fn new(case_sensitive: bool, normalized: bool) -> Self { + Self { + case_sensitive, + normalized, + } + } + + fn prepare<'a>(self, value: &'a str) -> Cow<'a, str> { + if self.case_sensitive && !self.normalized { + return Cow::Borrowed(value); + } + + let value = if self.case_sensitive { + Cow::Borrowed(value) + } else { + Cow::Owned(value.to_lowercase()) + }; + if !self.normalized { + return value; + } + + Cow::Owned( + value + .chars() + .filter(|ch| ch.is_alphanumeric()) + .collect::(), + ) + } +} diff --git a/codex-rs/ext/memories/src/schema.rs b/codex-rs/ext/memories/src/schema.rs new file mode 100644 index 0000000000..977cd5679a --- /dev/null +++ b/codex-rs/ext/memories/src/schema.rs @@ -0,0 +1,42 @@ +use schemars::JsonSchema; +use schemars::r#gen::SchemaSettings; +use serde_json::Map; +use serde_json::Value; + +pub(crate) fn input_schema_for() -> Value { + schema_for::(/*option_add_null_type*/ false) +} + +pub(crate) fn output_schema_for() -> Value { + schema_for::(/*option_add_null_type*/ true) +} + +fn schema_for(option_add_null_type: bool) -> Value { + let schema = SchemaSettings::draft2019_09() + .with(|settings| { + settings.inline_subschemas = true; + settings.option_add_null_type = option_add_null_type; + }) + .into_generator() + .into_root_schema_for::(); + let schema_value = serde_json::to_value(schema) + .unwrap_or_else(|err| panic!("generated tool schema should serialize: {err}")); + let Value::Object(mut schema_object) = schema_value else { + unreachable!("root tool schema must be an object"); + }; + + let mut tool_schema = Map::new(); + for key in [ + "properties", + "required", + "type", + "additionalProperties", + "$defs", + "definitions", + ] { + if let Some(value) = schema_object.remove(key) { + tool_schema.insert(key.to_string(), value); + } + } + Value::Object(tool_schema) +} diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs new file mode 100644 index 0000000000..3d9cd9a79b --- /dev/null +++ b/codex-rs/ext/memories/src/tests.rs @@ -0,0 +1,311 @@ +use std::path::Path; +use std::sync::Arc; + +use codex_extension_api::ContextContributor; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionToolExecutor; +use codex_extension_api::PromptSlot; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolContributor; +use codex_extension_api::ToolName; +use codex_extension_api::ToolPayload; +use codex_tools::ToolOutput; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::PathExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use pretty_assertions::assert_eq; +use serde_json::json; + +use crate::extension::MemoriesExtension; +use crate::extension::MemoriesExtensionConfig; +use crate::local::LocalMemoriesBackend; + +#[test] +fn tools_are_not_contributed_without_thread_config() { + let extension = MemoriesExtension; + + assert!( + extension + .tools( + &ExtensionData::new("session"), + &ExtensionData::new("thread") + ) + .is_empty() + ); +} + +#[test] +fn tools_are_not_contributed_when_disabled() { + let extension = MemoriesExtension; + let thread_store = ExtensionData::new("thread"); + thread_store.insert(MemoriesExtensionConfig { + enabled: false, + codex_home: test_path_buf("/tmp/codex-home").abs(), + }); + + assert!( + extension + .tools(&ExtensionData::new("session"), &thread_store) + .is_empty() + ); +} + +#[test] +fn tools_are_contributed_when_enabled() { + let extension = MemoriesExtension; + let thread_store = ExtensionData::new("thread"); + thread_store.insert(MemoriesExtensionConfig { + enabled: true, + codex_home: test_path_buf("/tmp/codex-home").abs(), + }); + + let tool_names = extension + .tools(&ExtensionData::new("session"), &thread_store) + .into_iter() + .map(|tool| tool.tool_name().to_string()) + .collect::>(); + + assert_eq!( + tool_names, + vec![ + "memory_list".to_string(), + "memory_read".to_string(), + "memory_search".to_string() + ] + ); +} + +#[tokio::test] +async fn prompt_contribution_uses_memory_summary_when_enabled() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let memories_dir = tempdir.path().join("memories"); + tokio::fs::create_dir_all(&memories_dir) + .await + .expect("create memories dir"); + tokio::fs::write( + memories_dir.join("memory_summary.md"), + "Remember repository-specific implementation preferences.", + ) + .await + .expect("write memory summary"); + + let extension = MemoriesExtension; + let thread_store = ExtensionData::new("thread"); + thread_store.insert(MemoriesExtensionConfig { + enabled: true, + codex_home: tempdir.path().abs(), + }); + + let fragments = extension + .contribute(&ExtensionData::new("session"), &thread_store) + .await; + + assert_eq!(fragments.len(), 1); + assert_eq!(fragments[0].slot(), PromptSlot::DeveloperPolicy); + assert!( + fragments[0] + .text() + .contains("Remember repository-specific implementation preferences.") + ); +} + +#[tokio::test] +async fn read_tool_reads_memory_file() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let memory_root = tempdir.path().join("memories"); + tokio::fs::create_dir_all(&memory_root) + .await + .expect("create memories dir"); + tokio::fs::write( + memory_root.join("MEMORY.md"), + "first line\nsecond needle line\nthird line\n", + ) + .await + .expect("write memory"); + let tool = memory_tool(&memory_root, crate::READ_TOOL_NAME); + let payload = ToolPayload::Function { + arguments: json!({ + "path": "MEMORY.md", + "line_offset": 2, + "max_lines": 1 + }) + .to_string(), + }; + + let output = tool + .handle(ToolCall { + call_id: "call-1".to_string(), + tool_name: ToolName::plain(crate::READ_TOOL_NAME), + payload: payload.clone(), + }) + .await + .expect("read should succeed"); + + assert_eq!( + output.post_tool_use_response("call-1", &payload), + Some(json!({ + "path": "MEMORY.md", + "content": "second needle line\n", + "start_line_number": 2, + "truncated": true + })) + ); +} + +#[tokio::test] +async fn search_tool_accepts_multiple_queries() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let memory_root = tempdir.path().join("memories"); + tokio::fs::create_dir_all(&memory_root) + .await + .expect("create memories dir"); + tokio::fs::write( + memory_root.join("MEMORY.md"), + "alpha only\nneedle only\nalpha needle\n", + ) + .await + .expect("write memory"); + let tool = memory_tool(&memory_root, crate::SEARCH_TOOL_NAME); + let payload = ToolPayload::Function { + arguments: json!({ + "queries": ["alpha", "needle"], + "case_sensitive": false + }) + .to_string(), + }; + + let output = tool + .handle(ToolCall { + call_id: "call-1".to_string(), + tool_name: ToolName::plain(crate::SEARCH_TOOL_NAME), + payload: payload.clone(), + }) + .await + .expect("search should succeed"); + + assert_eq!( + output.post_tool_use_response("call-1", &payload), + Some(json!({ + "queries": ["alpha", "needle"], + "match_mode": { + "type": "any" + }, + "path": null, + "matches": [ + { + "path": "MEMORY.md", + "match_line_number": 1, + "content_start_line_number": 1, + "content": "alpha only", + "matched_queries": ["alpha"] + }, + { + "path": "MEMORY.md", + "match_line_number": 2, + "content_start_line_number": 2, + "content": "needle only", + "matched_queries": ["needle"] + }, + { + "path": "MEMORY.md", + "match_line_number": 3, + "content_start_line_number": 3, + "content": "alpha needle", + "matched_queries": ["alpha", "needle"] + } + ], + "next_cursor": null, + "truncated": false + })) + ); +} + +#[tokio::test] +async fn search_tool_accepts_windowed_all_match_mode() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let memory_root = tempdir.path().join("memories"); + tokio::fs::create_dir_all(&memory_root) + .await + .expect("create memories dir"); + tokio::fs::write(memory_root.join("MEMORY.md"), "alpha\nmiddle\nneedle\n") + .await + .expect("write memory"); + let tool = memory_tool(&memory_root, crate::SEARCH_TOOL_NAME); + let payload = ToolPayload::Function { + arguments: json!({ + "queries": ["alpha", "needle"], + "match_mode": { + "type": "all_within_lines", + "line_count": 3 + } + }) + .to_string(), + }; + + let output = tool + .handle(ToolCall { + call_id: "call-1".to_string(), + tool_name: ToolName::plain(crate::SEARCH_TOOL_NAME), + payload: payload.clone(), + }) + .await + .expect("search should succeed"); + + assert_eq!( + output.post_tool_use_response("call-1", &payload), + Some(json!({ + "queries": ["alpha", "needle"], + "match_mode": { + "type": "all_within_lines", + "line_count": 3 + }, + "path": null, + "matches": [ + { + "path": "MEMORY.md", + "match_line_number": 1, + "content_start_line_number": 1, + "content": "alpha\nmiddle\nneedle", + "matched_queries": ["alpha", "needle"] + } + ], + "next_cursor": null, + "truncated": false + })) + ); +} + +#[tokio::test] +async fn search_tool_rejects_legacy_single_query() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let memory_root = tempdir.path().join("memories"); + tokio::fs::create_dir_all(&memory_root) + .await + .expect("create memories dir"); + let tool = memory_tool(&memory_root, crate::SEARCH_TOOL_NAME); + let payload = ToolPayload::Function { + arguments: json!({ + "query": "needle", + }) + .to_string(), + }; + + let err = tool + .handle(ToolCall { + call_id: "call-1".to_string(), + tool_name: ToolName::plain(crate::SEARCH_TOOL_NAME), + payload, + }) + .await + .expect_err("legacy query field should be rejected"); + + assert!(err.to_string().contains("unknown field")); + assert!(err.to_string().contains("query")); +} + +fn memory_tool(memory_root: &Path, tool_name: &str) -> Arc { + crate::tools::memory_tools(LocalMemoriesBackend::from_memory_root(memory_root)) + .into_iter() + .find(|tool| tool.tool_name().to_string() == tool_name) + .unwrap_or_else(|| panic!("{tool_name} tool should be registered")) +} diff --git a/codex-rs/ext/memories/src/tools/list.rs b/codex-rs/ext/memories/src/tools/list.rs new file mode 100644 index 0000000000..bd1599047e --- /dev/null +++ b/codex-rs/ext/memories/src/tools/list.rs @@ -0,0 +1,71 @@ +use codex_extension_api::ExtensionToolExecutor; +use codex_extension_api::ExtensionToolFuture; +use codex_extension_api::JsonToolOutput; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolName; +use codex_extension_api::ToolSpec; +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::json; + +use crate::DEFAULT_LIST_MAX_RESULTS; +use crate::LIST_TOOL_NAME; +use crate::MAX_LIST_RESULTS; +use crate::backend::ListMemoriesRequest; +use crate::backend::ListMemoriesResponse; +use crate::backend::MemoriesBackend; + +use super::backend_error_to_function_call; +use super::clamp_max_results; +use super::function_tool; +use super::parse_args; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ListArgs { + path: Option, + cursor: Option, + #[schemars(range(min = 1))] + max_results: Option, +} + +#[derive(Clone)] +pub(super) struct ListTool { + pub(super) backend: B, +} + +impl ExtensionToolExecutor for ListTool +where + B: MemoriesBackend, +{ + fn tool_name(&self) -> ToolName { + ToolName::plain(LIST_TOOL_NAME) + } + + fn spec(&self) -> Option { + Some(function_tool::( + LIST_TOOL_NAME, + "List immediate files and directories under a path in the Codex memories store.", + )) + } + + fn handle(&self, call: ToolCall) -> ExtensionToolFuture<'_> { + let backend = self.backend.clone(); + Box::pin(async move { + let args: ListArgs = parse_args(&call)?; + let response = backend + .list(ListMemoriesRequest { + path: args.path, + cursor: args.cursor, + max_results: clamp_max_results( + args.max_results, + DEFAULT_LIST_MAX_RESULTS, + MAX_LIST_RESULTS, + ), + }) + .await + .map_err(backend_error_to_function_call)?; + Ok(JsonToolOutput::new(json!(response))) + }) + } +} diff --git a/codex-rs/ext/memories/src/tools/mod.rs b/codex-rs/ext/memories/src/tools/mod.rs new file mode 100644 index 0000000000..b488cc5e87 --- /dev/null +++ b/codex-rs/ext/memories/src/tools/mod.rs @@ -0,0 +1,78 @@ +use std::sync::Arc; + +use codex_extension_api::ExtensionToolExecutor; +use codex_extension_api::FunctionCallError; +use codex_extension_api::ResponsesApiTool; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolSpec; +use codex_extension_api::parse_tool_input_schema; +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::Value; + +use crate::backend::MemoriesBackend; +use crate::backend::MemoriesBackendError; +use crate::schema; + +mod list; +mod read; +mod search; + +pub(crate) fn memory_tools(backend: B) -> Vec> +where + B: MemoriesBackend, +{ + vec![ + Arc::new(list::ListTool { + backend: backend.clone(), + }), + Arc::new(read::ReadTool { + backend: backend.clone(), + }), + Arc::new(search::SearchTool { backend }), + ] +} + +fn function_tool(name: &str, description: &str) -> ToolSpec { + ToolSpec::Function(ResponsesApiTool { + name: name.to_string(), + description: description.to_string(), + strict: false, + defer_loading: None, + parameters: parse_tool_input_schema(&schema::input_schema_for::()) + .unwrap_or_else(|err| panic!("generated input schema for {name} should parse: {err}")), + output_schema: Some(schema::output_schema_for::()), + }) +} + +fn parse_args Deserialize<'de>>(call: &ToolCall) -> Result { + let arguments = call.function_arguments()?; + let value = if arguments.trim().is_empty() { + Value::Object(serde_json::Map::new()) + } else { + serde_json::from_str(arguments) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))? + }; + serde_json::from_value(value).map_err(|err| FunctionCallError::RespondToModel(err.to_string())) +} + +fn clamp_max_results(requested: Option, default: usize, max: usize) -> usize { + requested.unwrap_or(default).clamp(1, max) +} + +fn backend_error_to_function_call(err: MemoriesBackendError) -> FunctionCallError { + match err { + MemoriesBackendError::InvalidPath { .. } + | MemoriesBackendError::InvalidCursor { .. } + | MemoriesBackendError::NotFound { .. } + | MemoriesBackendError::InvalidLineOffset + | MemoriesBackendError::InvalidMaxLines + | MemoriesBackendError::LineOffsetExceedsFileLength + | MemoriesBackendError::NotFile { .. } + | MemoriesBackendError::EmptyQuery + | MemoriesBackendError::InvalidMatchWindow => { + FunctionCallError::RespondToModel(err.to_string()) + } + MemoriesBackendError::Io(_) => FunctionCallError::Fatal(err.to_string()), + } +} diff --git a/codex-rs/ext/memories/src/tools/read.rs b/codex-rs/ext/memories/src/tools/read.rs new file mode 100644 index 0000000000..ec411a9947 --- /dev/null +++ b/codex-rs/ext/memories/src/tools/read.rs @@ -0,0 +1,67 @@ +use codex_extension_api::ExtensionToolExecutor; +use codex_extension_api::ExtensionToolFuture; +use codex_extension_api::JsonToolOutput; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolName; +use codex_extension_api::ToolSpec; +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::json; + +use crate::DEFAULT_READ_MAX_TOKENS; +use crate::READ_TOOL_NAME; +use crate::backend::MemoriesBackend; +use crate::backend::ReadMemoryRequest; +use crate::backend::ReadMemoryResponse; + +use super::backend_error_to_function_call; +use super::function_tool; +use super::parse_args; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ReadArgs { + path: String, + #[schemars(range(min = 1))] + line_offset: Option, + #[schemars(range(min = 1))] + max_lines: Option, +} + +#[derive(Clone)] +pub(super) struct ReadTool { + pub(super) backend: B, +} + +impl ExtensionToolExecutor for ReadTool +where + B: MemoriesBackend, +{ + fn tool_name(&self) -> ToolName { + ToolName::plain(READ_TOOL_NAME) + } + + fn spec(&self) -> Option { + Some(function_tool::( + READ_TOOL_NAME, + "Read a Codex memory file by relative path, optionally starting at a 1-indexed line offset and limiting the number of lines returned.", + )) + } + + fn handle(&self, call: ToolCall) -> ExtensionToolFuture<'_> { + let backend = self.backend.clone(); + Box::pin(async move { + let args: ReadArgs = parse_args(&call)?; + let response = backend + .read(ReadMemoryRequest { + path: args.path, + line_offset: args.line_offset.unwrap_or(1), + max_lines: args.max_lines, + max_tokens: DEFAULT_READ_MAX_TOKENS, + }) + .await + .map_err(backend_error_to_function_call)?; + Ok(JsonToolOutput::new(json!(response))) + }) + } +} diff --git a/codex-rs/ext/memories/src/tools/search.rs b/codex-rs/ext/memories/src/tools/search.rs new file mode 100644 index 0000000000..38863bcad5 --- /dev/null +++ b/codex-rs/ext/memories/src/tools/search.rs @@ -0,0 +1,90 @@ +use codex_extension_api::ExtensionToolExecutor; +use codex_extension_api::ExtensionToolFuture; +use codex_extension_api::JsonToolOutput; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolName; +use codex_extension_api::ToolSpec; +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::json; + +use crate::DEFAULT_SEARCH_MAX_RESULTS; +use crate::MAX_SEARCH_RESULTS; +use crate::SEARCH_TOOL_NAME; +use crate::backend::MemoriesBackend; +use crate::backend::SearchMatchMode; +use crate::backend::SearchMemoriesRequest; +use crate::backend::SearchMemoriesResponse; + +use super::backend_error_to_function_call; +use super::clamp_max_results; +use super::function_tool; +use super::parse_args; + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct SearchArgs { + #[schemars(length(min = 1))] + queries: Vec, + match_mode: Option, + path: Option, + cursor: Option, + #[schemars(range(min = 0))] + context_lines: Option, + case_sensitive: Option, + normalized: Option, + #[schemars(range(min = 1))] + max_results: Option, +} + +#[derive(Clone)] +pub(super) struct SearchTool { + pub(super) backend: B, +} + +impl ExtensionToolExecutor for SearchTool +where + B: MemoriesBackend, +{ + fn tool_name(&self) -> ToolName { + ToolName::plain(SEARCH_TOOL_NAME) + } + + fn spec(&self) -> Option { + Some(function_tool::( + SEARCH_TOOL_NAME, + "Search Codex memory files for substring matches, optionally normalizing separators or requiring all query substrings on the same line or within a line window.", + )) + } + + fn handle(&self, call: ToolCall) -> ExtensionToolFuture<'_> { + let backend = self.backend.clone(); + Box::pin(async move { + let args: SearchArgs = parse_args(&call)?; + let response = backend + .search(args.into_request()) + .await + .map_err(backend_error_to_function_call)?; + Ok(JsonToolOutput::new(json!(response))) + }) + } +} + +impl SearchArgs { + fn into_request(self) -> SearchMemoriesRequest { + SearchMemoriesRequest { + queries: self.queries, + match_mode: self.match_mode.unwrap_or(SearchMatchMode::Any), + path: self.path, + cursor: self.cursor, + context_lines: self.context_lines.unwrap_or(0), + case_sensitive: self.case_sensitive.unwrap_or(true), + normalized: self.normalized.unwrap_or(false), + max_results: clamp_max_results( + self.max_results, + DEFAULT_SEARCH_MAX_RESULTS, + MAX_SEARCH_RESULTS, + ), + } + } +} diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 7665a4ca8b..b50d14f4eb 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -25,6 +25,8 @@ pub struct MultiAgentV2ConfigToml { pub subagent_usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hide_spawn_agent_metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub non_code_mode_only: Option, } impl FeatureConfig for MultiAgentV2ConfigToml { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 955ae44c00..75535cbaaa 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -162,7 +162,7 @@ pub enum Feature { ToolSearch, /// Always defer MCP tools behind tool_search instead of exposing small sets directly. ToolSearchAlwaysDeferMcpTools, - /// Expose placeholder tools for unavailable historical tool calls. + /// Removed compatibility flag for the deleted unavailable-tool placeholder backfill. UnavailableDummyTools, /// Enable discoverable tool suggestions for apps. ToolSuggest, @@ -188,6 +188,8 @@ pub enum Feature { ComputerUse, /// Temporary internal-only flag for PS-backed remote plugin catalog development. RemotePlugin, + /// Enable remote plugin sharing flows. + PluginSharing, /// Show the startup prompt for migrating external agent config into Codex. ExternalMigration, /// Allow the model to invoke the built-in image generation tool. @@ -231,7 +233,8 @@ pub enum Feature { TuiAppServer, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, - /// Enable workspace-specific owner nudge copy and prompts in the TUI. + /// Removed compatibility flag retained as a no-op now that workspace owner + /// usage nudges are always enabled. WorkspaceOwnerUsageNudge, /// Legacy rollout flag for Responses API WebSocket transport experiments. ResponsesWebsockets, @@ -955,8 +958,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::UnavailableDummyTools, key: "unavailable_dummy_tools", - stage: Stage::Stable, - default_enabled: true, + stage: Stage::Removed, + default_enabled: false, }, FeatureSpec { id: Feature::ToolSuggest, @@ -1006,6 +1009,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::PluginSharing, + key: "plugin_sharing", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::ExternalMigration, key: "external_migration", @@ -1153,7 +1162,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::WorkspaceOwnerUsageNudge, key: "workspace_owner_usage_nudge", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index a635ca0740..04e236f5ee 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -481,6 +481,7 @@ usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." hide_spawn_agent_metadata = true +non_code_mode_only = true "#, ) .expect("features table should deserialize"); @@ -500,6 +501,7 @@ hide_spawn_agent_metadata = true root_agent_usage_hint_text: Some("Root guidance.".to_string()), subagent_usage_hint_text: Some("Subagent guidance.".to_string()), hide_spawn_agent_metadata: Some(true), + non_code_mode_only: Some(true), })) ); } @@ -535,6 +537,7 @@ usage_hint_enabled = false root_agent_usage_hint_text: None, subagent_usage_hint_text: None, hide_spawn_agent_metadata: None, + non_code_mode_only: None, })) ); } diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 8dac67e30c..51d242efdd 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs; use std::path::Path; @@ -19,7 +20,6 @@ use codex_plugin::PluginHookSource; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; -use std::collections::HashMap; use super::ConfiguredHandler; use super::HookListEntry; @@ -41,21 +41,45 @@ struct HookHandlerSource<'a> { key_source: String, source: HookSource, is_managed: bool, + bypass_hook_trust: bool, hook_states: &'a HashMap, env: HashMap, plugin_id: Option, } +#[derive(Clone, Copy)] +struct HookDiscoveryPolicy { + allow_managed_hooks_only: bool, + bypass_hook_trust: bool, +} + +impl HookDiscoveryPolicy { + fn allows(self, source: &HookHandlerSource<'_>) -> bool { + !self.allow_managed_hooks_only || source.is_managed + } +} + pub(crate) fn discover_handlers( config_layer_stack: Option<&ConfigLayerStack>, plugin_hook_sources: Vec, plugin_hook_load_warnings: Vec, + bypass_hook_trust: bool, ) -> DiscoveryResult { let mut handlers = Vec::new(); let mut hook_entries = Vec::new(); let mut warnings = plugin_hook_load_warnings; let mut display_order = 0_i64; let hook_states = hook_states_from_stack(config_layer_stack); + let policy = HookDiscoveryPolicy { + allow_managed_hooks_only: config_layer_stack.is_some_and(|config_layer_stack| { + config_layer_stack + .requirements() + .allow_managed_hooks_only + .as_ref() + .is_some_and(|requirement| requirement.value) + }), + bypass_hook_trust, + }; if let Some(config_layer_stack) = config_layer_stack { append_managed_requirement_handlers( @@ -65,6 +89,7 @@ pub(crate) fn discover_handlers( &mut display_order, config_layer_stack, &hook_states, + policy, ); for layer in config_layer_stack.get_layers( @@ -72,7 +97,21 @@ pub(crate) fn discover_handlers( /*include_disabled*/ false, ) { let (hook_source, is_managed) = hook_metadata_for_config_layer_source(&layer.name); - let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings); + let policy_path = config_toml_source_path(layer); + let policy_source = HookHandlerSource { + path: &policy_path, + key_source: policy_path.display().to_string(), + source: hook_source, + is_managed, + bypass_hook_trust: false, + hook_states: &hook_states, + env: HashMap::new(), + plugin_id: None, + }; + if !policy.allows(&policy_source) { + continue; + } + let json_hooks = load_hooks_json(layer.hooks_config_folder().as_deref(), &mut warnings); let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings); if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) = @@ -98,11 +137,13 @@ pub(crate) fn discover_handlers( key_source: source_path.display().to_string(), source: hook_source, is_managed, + bypass_hook_trust: policy.bypass_hook_trust, hook_states: &hook_states, env: HashMap::new(), plugin_id: None, }, hook_events, + policy, ); } } @@ -115,6 +156,7 @@ pub(crate) fn discover_handlers( &mut display_order, plugin_hook_sources, &hook_states, + policy, ); DiscoveryResult { @@ -131,15 +173,12 @@ fn append_managed_requirement_handlers( display_order: &mut i64, config_layer_stack: &ConfigLayerStack, hook_states: &HashMap, + policy: HookDiscoveryPolicy, ) { let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else { return; }; - let Some(source_path) = - managed_hooks_source_path(managed_hooks.get(), managed_hooks.source.as_ref(), warnings) - else { - return; - }; + let source_path = managed_hooks_source_path(managed_hooks.get(), managed_hooks.source.as_ref()); append_hook_events( handlers, hook_entries, @@ -150,11 +189,13 @@ fn append_managed_requirement_handlers( key_source: source_path.display().to_string(), source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), is_managed: true, + bypass_hook_trust: false, hook_states, env: HashMap::new(), plugin_id: None, }, managed_hooks.get().hooks.clone(), + policy, ); } @@ -165,6 +206,7 @@ fn append_plugin_hook_sources( display_order: &mut i64, plugin_hook_sources: Vec, hook_states: &HashMap, + policy: HookDiscoveryPolicy, ) { for source in plugin_hook_sources { let PluginHookSource { @@ -198,11 +240,13 @@ fn append_plugin_hook_sources( ), source: HookSource::Plugin, is_managed: false, + bypass_hook_trust: policy.bypass_hook_trust, hook_states, env, plugin_id: Some(plugin_id), }, hooks, + policy, ); } } @@ -210,45 +254,35 @@ fn append_plugin_hook_sources( fn managed_hooks_source_path( managed_hooks: &ManagedHooksRequirementsToml, requirement_source: Option<&RequirementSource>, - warnings: &mut Vec, -) -> Option { - let source = requirement_source - .map(ToString::to_string) - .unwrap_or_else(|| "managed requirements".to_string()); - let Some(source_path) = managed_hooks.managed_dir_for_current_platform() else { - warnings.push(format!( - "skipping managed hooks from {source}: no managed hook directory is configured for this platform" - )); - return None; - }; +) -> AbsolutePathBuf { + if let Some(source_path) = managed_hooks.managed_dir_for_current_platform() + && source_path.is_absolute() + && let Ok(source_path) = AbsolutePathBuf::from_absolute_path(source_path) + { + return source_path; + } - if !source_path.is_absolute() { - warnings.push(format!( - "skipping managed hooks from {source}: managed hook directory {} is not absolute", - source_path.display() - )); - None - } else if !source_path.exists() { - warnings.push(format!( - "skipping managed hooks from {source}: managed hook directory {} does not exist", - source_path.display() - )); - None - } else if !source_path.is_dir() { - warnings.push(format!( - "skipping managed hooks from {source}: managed hook directory {} is not a directory", - source_path.display() - )); - None - } else { - AbsolutePathBuf::from_absolute_path(source_path) - .inspect_err(|err| { - warnings.push(format!( - "skipping managed hooks from {source}: could not normalize managed hook directory {}: {err}", - source_path.display() - )); - }) - .ok() + fallback_managed_hooks_source_path(requirement_source) +} + +fn fallback_managed_hooks_source_path( + requirement_source: Option<&RequirementSource>, +) -> AbsolutePathBuf { + match requirement_source { + Some(RequirementSource::SystemRequirementsToml { file }) + | Some(RequirementSource::LegacyManagedConfigTomlFromFile { file }) => file.clone(), + Some(RequirementSource::MdmManagedPreferences { domain, key }) => { + synthetic_layer_path(&format!("/requirements.toml")) + } + Some(RequirementSource::CloudRequirements) => { + synthetic_layer_path("/requirements.toml") + } + Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => { + synthetic_layer_path("/managed_config.toml") + } + Some(RequirementSource::Unknown) | None => { + synthetic_layer_path("/requirements.toml") + } } } @@ -320,7 +354,10 @@ fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf { ConfigLayerSource::System { file } | ConfigLayerSource::User { file } | ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => file.clone(), - ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder.join(CONFIG_TOML_FILE), + ConfigLayerSource::Project { dot_codex_folder } => layer + .hooks_config_folder() + .unwrap_or_else(|| dot_codex_folder.clone()) + .join(CONFIG_TOML_FILE), ConfigLayerSource::Mdm { domain, key } => { synthetic_layer_path(&format!("/{CONFIG_TOML_FILE}")) } @@ -350,7 +387,12 @@ fn append_hook_events( display_order: &mut i64, source: HookHandlerSource<'_>, hook_events: HookEventsToml, + policy: HookDiscoveryPolicy, ) { + if !policy.allows(&source) { + return; + } + for (event_name, groups) in hook_events.into_matcher_groups() { append_matcher_groups( handlers, @@ -451,10 +493,11 @@ fn append_matcher_groups( trust_status, }); if enabled - && matches!( - trust_status, - HookTrustStatus::Managed | HookTrustStatus::Trusted - ) + && (source.bypass_hook_trust + || matches!( + trust_status, + HookTrustStatus::Managed | HookTrustStatus::Trusted + )) { handlers.push(ConfiguredHandler { event_name, @@ -586,6 +629,7 @@ mod tests { use codex_config::HookStateToml; use codex_config::MatcherGroup; use codex_config::TomlValue; + use codex_protocol::protocol::HookTrustStatus; fn source_path() -> AbsolutePathBuf { test_path_buf("/tmp/hooks.json").abs() @@ -604,6 +648,24 @@ mod tests { key_source: path.display().to_string(), source: hook_source(), is_managed: true, + bypass_hook_trust: false, + hook_states, + env: std::collections::HashMap::new(), + plugin_id: None, + } + } + + fn unmanaged_hook_handler_source<'a>( + path: &'a AbsolutePathBuf, + hook_states: &'a std::collections::HashMap, + bypass_hook_trust: bool, + ) -> super::HookHandlerSource<'a> { + super::HookHandlerSource { + path, + key_source: path.display().to_string(), + source: HookSource::User, + is_managed: false, + bypass_hook_trust, hook_states, env: std::collections::HashMap::new(), plugin_id: None, @@ -693,6 +755,72 @@ mod tests { ); } + #[test] + fn bypass_hook_trust_allows_enabled_untrusted_handlers() { + let mut handlers = Vec::new(); + let mut hook_entries = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + let source_path = source_path(); + let hook_states = std::collections::HashMap::new(); + + append_matcher_groups( + &mut handlers, + &mut hook_entries, + &mut warnings, + &mut display_order, + &unmanaged_hook_handler_source( + &source_path, + &hook_states, + /*bypass_hook_trust*/ true, + ), + HookEventName::PreToolUse, + vec![command_group(Some("Bash"))], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!(handlers.len(), 1); + assert_eq!(hook_entries.len(), 1); + assert_eq!(hook_entries[0].trust_status, HookTrustStatus::Untrusted); + assert_eq!(hook_entries[0].enabled, true); + } + + #[test] + fn bypass_hook_trust_respects_disabled_handlers() { + let mut handlers = Vec::new(); + let mut hook_entries = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + let source_path = source_path(); + let hook_states = std::collections::HashMap::from([( + format!("{}:pre_tool_use:0:0", source_path.display()), + HookStateToml { + enabled: Some(false), + trusted_hash: None, + }, + )]); + + append_matcher_groups( + &mut handlers, + &mut hook_entries, + &mut warnings, + &mut display_order, + &unmanaged_hook_handler_source( + &source_path, + &hook_states, + /*bypass_hook_trust*/ true, + ), + HookEventName::PreToolUse, + vec![command_group(Some("Bash"))], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!(handlers, Vec::::new()); + assert_eq!(hook_entries.len(), 1); + assert_eq!(hook_entries[0].trust_status, HookTrustStatus::Untrusted); + assert_eq!(hook_entries[0].enabled, false); + } + #[test] fn pre_tool_use_treats_star_matcher_as_match_all() { let mut handlers = Vec::new(); diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 579f7594d4..441c24314c 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -106,6 +106,7 @@ pub(crate) struct ClaudeHooksEngine { impl ClaudeHooksEngine { pub(crate) fn new( enabled: bool, + bypass_hook_trust: bool, config_layer_stack: Option<&ConfigLayerStack>, plugin_hook_sources: Vec, plugin_hook_load_warnings: Vec, @@ -125,6 +126,7 @@ impl ClaudeHooksEngine { config_layer_stack, plugin_hook_sources, plugin_hook_load_warnings, + bypass_hook_trust, ); Self { handlers: discovered.handlers, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index cb56826900..b7877b6a80 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -15,6 +15,7 @@ use codex_config::HookHandlerConfig; use codex_config::ManagedHooksRequirementsToml; use codex_config::MatcherGroup; use codex_config::RequirementSource; +use codex_config::Sourced; use codex_config::TomlValue; use codex_plugin::PluginHookSource; use codex_plugin::PluginId; @@ -55,6 +56,88 @@ fn managed_hooks_for_current_platform( } } +fn pre_tool_use_hook_events(command: impl Into) -> HookEventsToml { + HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: command.into(), + command_windows: None, + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }], + }], + ..Default::default() + } +} + +fn config_toml_with_pre_tool_use(command: &str) -> TomlValue { + let mut config_toml = TomlValue::Table(Default::default()); + let TomlValue::Table(config_table) = &mut config_toml else { + unreachable!("config TOML root should be a table"); + }; + let mut hooks_table = TomlValue::Table(Default::default()); + let TomlValue::Table(hooks_entries) = &mut hooks_table else { + unreachable!("hooks entry should be a table"); + }; + let mut pre_tool_use_group = TomlValue::Table(Default::default()); + let TomlValue::Table(pre_tool_use_group_entries) = &mut pre_tool_use_group else { + unreachable!("PreToolUse group should be a table"); + }; + pre_tool_use_group_entries.insert( + "matcher".to_string(), + TomlValue::String("^Bash$".to_string()), + ); + let mut handler = TomlValue::Table(Default::default()); + let TomlValue::Table(handler_entries) = &mut handler else { + unreachable!("PreToolUse handler should be a table"); + }; + handler_entries.insert("type".to_string(), TomlValue::String("command".to_string())); + handler_entries.insert( + "command".to_string(), + TomlValue::String(command.to_string()), + ); + handler_entries.insert("timeout".to_string(), TomlValue::Integer(10)); + handler_entries.insert( + "statusMessage".to_string(), + TomlValue::String("checking".to_string()), + ); + pre_tool_use_group_entries.insert("hooks".to_string(), TomlValue::Array(vec![handler])); + hooks_entries.insert( + "PreToolUse".to_string(), + TomlValue::Array(vec![pre_tool_use_group]), + ); + config_table.insert("hooks".to_string(), hooks_table); + config_toml +} + +fn requirements_with_managed_hooks_only( + allow_managed_hooks_only: bool, + managed_hooks: Option, +) -> (ConfigRequirements, ConfigRequirementsToml) { + ( + ConfigRequirements { + allow_managed_hooks_only: Some(Sourced::new( + allow_managed_hooks_only, + RequirementSource::CloudRequirements, + )), + managed_hooks: managed_hooks.clone().map(|hooks| { + ConstrainedWithSource::new( + Constrained::allow_any(hooks), + Some(RequirementSource::CloudRequirements), + ) + }), + ..ConfigRequirements::default() + }, + ConfigRequirementsToml { + allow_managed_hooks_only: Some(allow_managed_hooks_only), + hooks: managed_hooks, + ..ConfigRequirementsToml::default() + }, + ) +} + #[tokio::test] async fn requirements_managed_hooks_execute_from_managed_dir() { let temp = tempdir().expect("create temp dir"); @@ -113,6 +196,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -128,6 +212,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, + bypass_hook_trust: false, config_layer_stack: Some(config_layer_stack.clone()), plugin_hook_sources: Vec::new(), plugin_hook_load_warnings: Vec::new(), @@ -212,6 +297,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -289,6 +375,7 @@ fn unknown_requirement_source_hooks_stay_managed() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -300,8 +387,12 @@ fn unknown_requirement_source_hooks_stay_managed() { assert_eq!(engine.handlers.len(), 1); assert_eq!(engine.handlers[0].source, HookSource::Unknown); - let discovered = - super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); + let discovered = super::discovery::discover_handlers( + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + /*bypass_hook_trust*/ false, + ); assert_eq!(discovered.hook_entries.len(), 1); assert_eq!(discovered.hook_entries[0].source, HookSource::Unknown); assert_eq!(discovered.hook_entries[0].enabled, true); @@ -363,6 +454,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -374,8 +466,12 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { assert_eq!(engine.handlers.len(), 1); assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements); - let discovered = - super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); + let discovered = super::discovery::discover_handlers( + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + /*bypass_hook_trust*/ false, + ); assert_eq!(discovered.hook_entries.len(), 2); assert_eq!(discovered.hook_entries[0].key, managed_disabled_key); assert_eq!(discovered.hook_entries[0].enabled, true); @@ -420,6 +516,7 @@ fn user_disablement_does_not_filter_managed_layer_hooks() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -434,8 +531,12 @@ fn user_disablement_does_not_filter_managed_layer_hooks() { engine.handlers[0].source, HookSource::LegacyManagedConfigFile ); - let discovered = - super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); + let discovered = super::discovery::discover_handlers( + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + /*bypass_hook_trust*/ false, + ); assert_eq!(discovered.hook_entries.len(), 1); assert_eq!(discovered.hook_entries[0].key, managed_key); assert_eq!(discovered.hook_entries[0].enabled, true); @@ -503,6 +604,7 @@ fn trusted_plugin_hook_stack( /*config_layer_stack*/ None, plugin_hook_sources.to_vec(), Vec::new(), + /*bypass_hook_trust*/ false, ); let state = discovered .hook_entries @@ -535,7 +637,7 @@ fn trusted_plugin_hook_stack( } #[test] -fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { +fn requirements_managed_hooks_load_when_managed_dir_is_missing() { let temp = tempdir().expect("create temp dir"); let missing_dir = temp.path().join("missing-managed-hooks"); let managed_hooks = managed_hooks_for_current_platform( @@ -544,7 +646,7 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { - command: format!("python3 {}", missing_dir.join("pre.py").display()), + command: "echo hi".to_string(), command_windows: None, timeout_sec: Some(10), r#async: false, @@ -572,6 +674,7 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -581,30 +684,311 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { }, ); - assert!(engine.warnings().iter().any(|warning| { - warning.contains("managed hook directory") - && warning.contains("does not exist") - && warning.contains(&missing_dir.display().to_string()) - })); + assert!(engine.warnings().is_empty()); let cwd = cwd(); - assert!( - engine - .preview_pre_tool_use(&PreToolUseRequest { - session_id: ThreadId::new(), - turn_id: "turn-1".to_string(), - cwd, - transcript_path: None, - model: "gpt-test".to_string(), - permission_mode: "default".to_string(), - tool_name: "Bash".to_string(), - matcher_aliases: Vec::new(), - tool_use_id: "tool-1".to_string(), - tool_input: serde_json::json!({ "command": "echo hello" }), - }) - .is_empty() + let preview = engine.preview_pre_tool_use(&PreToolUseRequest { + session_id: ThreadId::new(), + turn_id: "turn-1".to_string(), + cwd, + transcript_path: None, + model: "gpt-test".to_string(), + permission_mode: "default".to_string(), + tool_name: "Bash".to_string(), + matcher_aliases: Vec::new(), + tool_use_id: "tool-1".to_string(), + tool_input: serde_json::json!({ "command": "echo hello" }), + }); + assert_eq!(preview.len(), 1); + assert_eq!(engine.handlers[0].command, "echo hi"); + assert_eq!( + engine.handlers[0].source_path, + AbsolutePathBuf::try_from(missing_dir).expect("absolute missing dir") ); } +#[test] +fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() { + let temp = tempdir().expect("create temp dir"); + let config_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let (requirements, requirements_toml) = requirements_with_managed_hooks_only( + /*allow_managed_hooks_only*/ false, /*managed_hooks*/ None, + ); + let config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { file: config_path }, + config_toml_with_pre_tool_use("python3 /tmp/user-hook.py"), + )], + requirements, + requirements_toml, + ) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + /*bypass_hook_trust*/ false, + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + ); + + assert!(engine.warnings().is_empty()); + assert!(engine.handlers.is_empty()); + let discovered = super::discovery::discover_handlers( + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + /*bypass_hook_trust*/ false, + ); + assert_eq!(discovered.hook_entries.len(), 1); + assert!(!discovered.hook_entries[0].is_managed); + assert_eq!( + discovered.hook_entries[0].command.as_deref(), + Some("python3 /tmp/user-hook.py") + ); +} + +#[test] +fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() { + let temp = tempdir().expect("create temp dir"); + let config_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let mut config_toml = config_toml_with_pre_tool_use("python3 /tmp/user-hook.py"); + let TomlValue::Table(config_table) = &mut config_toml else { + unreachable!("config TOML root should be a table"); + }; + config_table.insert( + "allow_managed_hooks_only".to_string(), + TomlValue::Boolean(true), + ); + let config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { file: config_path }, + config_toml, + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + /*bypass_hook_trust*/ false, + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + ); + + assert!(engine.warnings().is_empty()); + assert!(engine.handlers.is_empty()); + let discovered = super::discovery::discover_handlers( + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + /*bypass_hook_trust*/ false, + ); + assert_eq!(discovered.hook_entries.len(), 1); + assert!(!discovered.hook_entries[0].is_managed); + assert_eq!( + discovered.hook_entries[0].command.as_deref(), + Some("python3 /tmp/user-hook.py") + ); +} + +#[test] +fn allow_managed_hooks_only_skips_unmanaged_json_and_toml_hooks() { + let temp = tempdir().expect("create temp dir"); + let config_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let hooks_json_path = + AbsolutePathBuf::try_from(temp.path().join("hooks.json")).expect("absolute hooks path"); + fs::write( + hooks_json_path.as_path(), + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + { + "type": "command", + "command": "python3 /tmp/json-hook.py" + } + ] + } + ] + } + }"#, + ) + .expect("write hooks.json"); + let (requirements, requirements_toml) = requirements_with_managed_hooks_only( + /*allow_managed_hooks_only*/ true, /*managed_hooks*/ None, + ); + let config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { file: config_path }, + config_toml_with_pre_tool_use("python3 /tmp/toml-hook.py"), + )], + requirements, + requirements_toml, + ) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + /*bypass_hook_trust*/ false, + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + ); + + assert!(engine.handlers.is_empty()); + assert!(engine.warnings().is_empty()); +} + +#[test] +fn allow_managed_hooks_only_skips_unmanaged_plugin_hooks() { + let temp = tempdir().expect("create temp dir"); + let plugin_root = + AbsolutePathBuf::try_from(temp.path().join("demo-plugin")).expect("plugin root"); + let plugin_data_root = + AbsolutePathBuf::try_from(temp.path().join("plugin-data")).expect("plugin data root"); + let source_path = plugin_root.join("hooks/hooks.json"); + let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id"); + let plugin_hook_sources = vec![PluginHookSource { + plugin_id, + plugin_root, + plugin_data_root, + source_path, + source_relative_path: "hooks/hooks.json".to_string(), + hooks: pre_tool_use_hook_events("python3 /tmp/plugin-hook.py"), + }]; + let (requirements, requirements_toml) = requirements_with_managed_hooks_only( + /*allow_managed_hooks_only*/ true, /*managed_hooks*/ None, + ); + let config_layer_stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + /*bypass_hook_trust*/ false, + Some(&config_layer_stack), + plugin_hook_sources, + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + ); + + assert!(engine.handlers.is_empty()); + assert!(engine.warnings().is_empty()); +} + +#[test] +fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() { + let temp = tempdir().expect("create temp dir"); + let managed_dir = + AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path"); + fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir"); + let system_config_path = + AbsolutePathBuf::try_from(temp.path().join("system").join("config.toml")) + .expect("absolute system config path"); + let system_parent = system_config_path + .as_path() + .parent() + .expect("system config parent"); + fs::create_dir_all(system_parent).expect("create system config dir"); + let legacy_config_path = AbsolutePathBuf::try_from(temp.path().join("managed_config.toml")) + .expect("absolute legacy config path"); + + let managed_hooks = managed_hooks_for_current_platform( + managed_dir, + pre_tool_use_hook_events("python3 /tmp/requirements-hook.py"), + ); + let (requirements, requirements_toml) = requirements_with_managed_hooks_only( + /*allow_managed_hooks_only*/ true, + Some(managed_hooks), + ); + let config_layer_stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + ConfigLayerSource::Mdm { + domain: "com.openai.codex".to_string(), + key: "config".to_string(), + }, + config_toml_with_pre_tool_use("python3 /tmp/mdm-hook.py"), + ), + ConfigLayerEntry::new( + ConfigLayerSource::System { + file: system_config_path, + }, + config_toml_with_pre_tool_use("python3 /tmp/system-hook.py"), + ), + ConfigLayerEntry::new( + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: legacy_config_path, + }, + config_toml_with_pre_tool_use("python3 /tmp/legacy-file-hook.py"), + ), + ConfigLayerEntry::new( + ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + config_toml_with_pre_tool_use("python3 /tmp/legacy-mdm-hook.py"), + ), + ], + requirements, + requirements_toml, + ) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + /*bypass_hook_trust*/ false, + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + ); + + assert!(engine.warnings().is_empty()); + assert_eq!( + engine + .handlers + .iter() + .map(|handler| handler.command.as_str()) + .collect::>(), + vec![ + "python3 /tmp/requirements-hook.py", + "python3 /tmp/mdm-hook.py", + "python3 /tmp/system-hook.py", + "python3 /tmp/legacy-file-hook.py", + "python3 /tmp/legacy-mdm-hook.py", + ] + ); + let discovered = super::discovery::discover_handlers( + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + /*bypass_hook_trust*/ false, + ); + assert!(discovered.hook_entries.iter().all(|entry| entry.is_managed)); +} + #[test] fn discovers_hooks_from_json_and_toml_in_the_same_layer() { let temp = tempdir().expect("create temp dir"); @@ -681,6 +1065,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -772,6 +1157,7 @@ print(json.dumps({ ); let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), plugin_hook_sources.clone(), Vec::new(), @@ -799,6 +1185,7 @@ print(json.dumps({ let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, + bypass_hook_trust: false, config_layer_stack: None, plugin_hook_sources, plugin_hook_load_warnings: Vec::new(), @@ -882,6 +1269,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() { ); let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), plugin_hook_sources, Vec::new(), @@ -925,6 +1313,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() { fn plugin_hook_load_warnings_are_startup_warnings() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, /*config_layer_stack*/ None, Vec::new(), vec!["failed plugin hook".to_string()], diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 8b79ee873b..1f4e01aa59 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -30,6 +30,7 @@ use crate::types::HookResponse; pub struct HooksConfig { pub legacy_notify_argv: Option>, pub feature_enabled: bool, + pub bypass_hook_trust: bool, pub config_layer_stack: Option, pub plugin_hook_sources: Vec, pub plugin_hook_load_warnings: Vec, @@ -65,6 +66,7 @@ impl Hooks { .collect(); let engine = ClaudeHooksEngine::new( config.feature_enabled, + config.bypass_hook_trust, config.config_layer_stack.as_ref(), config.plugin_hook_sources, config.plugin_hook_load_warnings, @@ -212,6 +214,7 @@ pub fn list_hooks(config: HooksConfig) -> HookListOutcome { config.config_layer_stack.as_ref(), config.plugin_hook_sources, config.plugin_hook_load_warnings, + config.bypass_hook_trust, ); HookListOutcome { hooks: discovered.hook_entries, diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index f83fd4fd5a..2f9f354277 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -2,6 +2,7 @@ use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_protocol::ThreadId; use codex_protocol::config_types::SandboxMode; @@ -191,8 +192,11 @@ impl CodexToolCallParam { .map(|(k, v)| (k, json_to_toml(v))) .collect(); - let cfg = - Config::load_with_cli_overrides_and_harness_overrides(cli_overrides, overrides).await?; + let cfg = ConfigBuilder::default() + .cli_overrides(cli_overrides) + .harness_overrides(overrides) + .build() + .await?; Ok((prompt, cfg)) } diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 45daf99a01..d82de48f9e 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -6,7 +6,7 @@ use std::io::Result as IoResult; use std::sync::Arc; use codex_arg0::Arg0DispatchPaths; -use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; @@ -59,6 +59,7 @@ type IncomingMessage = JsonRpcMessage; pub async fn run_main( arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, + strict_config: bool, ) -> IoResult<()> { // Parse CLI overrides once and derive the base Config eagerly so later // components do not need to work with raw TOML values. @@ -68,7 +69,10 @@ pub async fn run_main( format!("error parsing -c overrides: {e}"), ) })?; - let config = Config::load_with_cli_overrides(cli_kv_overrides) + let config = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .strict_config(strict_config) + .build() .await .map_err(|e| { std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) diff --git a/codex-rs/mcp-server/src/main.rs b/codex-rs/mcp-server/src/main.rs index ce61fd04d1..220507446a 100644 --- a/codex-rs/mcp-server/src/main.rs +++ b/codex-rs/mcp-server/src/main.rs @@ -5,7 +5,12 @@ use codex_utils_cli::CliConfigOverrides; fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { - run_main(arg0_paths, CliConfigOverrides::default()).await?; + run_main( + arg0_paths, + CliConfigOverrides::default(), + /*strict_config*/ false, + ) + .await?; Ok(()) }) } diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 6856b80b71..65f71851d6 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -37,6 +37,7 @@ pub const OPENAI_PROVIDER_ID: &str = "openai"; pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; const AMAZON_BEDROCK_PROVIDER_NAME: &str = "Amazon Bedrock"; pub const AMAZON_BEDROCK_PROVIDER_ID: &str = "amazon-bedrock"; +pub const AMAZON_BEDROCK_GPT_5_4_MODEL_ID: &str = "openai.gpt-5.4"; pub const AMAZON_BEDROCK_DEFAULT_BASE_URL: &str = "https://bedrock-mantle.us-east-1.api.aws/openai/v1"; const AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_HEADER: &str = "x-amzn-mantle-client-agent"; diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index dc286fe6d6..d6fe1de095 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -1,3 +1,4 @@ +use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_4_MODEL_ID; use codex_models_manager::model_info::BASE_INSTRUCTIONS; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; @@ -18,7 +19,6 @@ use codex_protocol::openai_models::WebSearchToolType; const GPT_OSS_CONTEXT_WINDOW: i64 = 128_000; const GPT_5_4_CONTEXT_WINDOW: i64 = 272_000; const GPT_5_4_MAX_CONTEXT_WINDOW: i64 = 1_000_000; -const GPT_5_4_CMB_MODEL_ID: &str = "openai.gpt-5.4"; pub(crate) fn static_model_catalog() -> ModelsResponse { ModelsResponse { @@ -40,7 +40,7 @@ pub(crate) fn static_model_catalog() -> ModelsResponse { fn gpt_5_4_cmb_bedrock_model(priority: i32) -> ModelInfo { ModelInfo { - slug: GPT_5_4_CMB_MODEL_ID.to_string(), + slug: AMAZON_BEDROCK_GPT_5_4_MODEL_ID.to_string(), display_name: "gpt-5.4".to_string(), description: Some("Strong model for everyday coding.".to_string()), default_reasoning_level: Some(ReasoningEffort::Medium), @@ -155,7 +155,7 @@ mod tests { let catalog = static_model_catalog(); assert_eq!(catalog.models.len(), 3); - assert_eq!(catalog.models[0].slug, GPT_5_4_CMB_MODEL_ID); + assert_eq!(catalog.models[0].slug, AMAZON_BEDROCK_GPT_5_4_MODEL_ID); assert_eq!(catalog.models[1].slug, "openai.gpt-oss-120b"); assert_eq!(catalog.models[2].slug, "openai.gpt-oss-20b"); } @@ -166,7 +166,7 @@ mod tests { let cmb_model = catalog .models .iter() - .find(|model| model.slug == GPT_5_4_CMB_MODEL_ID) + .find(|model| model.slug == AMAZON_BEDROCK_GPT_5_4_MODEL_ID) .expect("Bedrock catalog should include GPT-5.4 CMB"); assert_eq!( diff --git a/codex-rs/model-provider/src/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index adca7d7d91..3940f73fcd 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -9,6 +9,7 @@ use codex_api::Provider; use codex_api::SharedAuthProvider; use codex_login::AuthManager; use codex_login::CodexAuth; +use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_4_MODEL_ID; use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::manager::SharedModelsManager; @@ -62,6 +63,10 @@ impl ModelProvider for AmazonBedrockModelProvider { } } + fn approval_review_preferred_model(&self) -> &'static str { + AMAZON_BEDROCK_GPT_5_4_MODEL_ID + } + fn auth_manager(&self) -> Option> { None } @@ -140,4 +145,16 @@ mod tests { } ); } + + #[test] + fn approval_review_preferred_model_uses_bedrock_gpt_5_4() { + let provider = AmazonBedrockModelProvider::new( + ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None), + ); + + assert_eq!( + provider.approval_review_preferred_model(), + AMAZON_BEDROCK_GPT_5_4_MODEL_ID + ); + } } diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 8e1d37b29d..1ef7c22962 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -70,6 +70,10 @@ impl std::error::Error for ProviderAccountError {} pub type ProviderAccountResult = std::result::Result; +/// Default model used for automatic approval review when a provider does not +/// require a backend-specific model ID. +pub const DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL: &str = "codex-auto-review"; + /// Runtime provider abstraction used by model execution. /// /// Implementations own provider-specific behavior for a model backend. The @@ -85,6 +89,13 @@ pub trait ModelProvider: fmt::Debug + Send + Sync { ProviderCapabilities::default() } + /// Returns the preferred model used for automatic approval review. + /// + /// Providers that require backend-specific model IDs should override this. + fn approval_review_preferred_model(&self) -> &'static str { + DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL + } + /// Returns whether requests made through this provider should include attestation. fn supports_attestation(&self) -> bool { false @@ -350,6 +361,19 @@ mod tests { assert_eq!(provider.capabilities(), ProviderCapabilities::default()); } + #[test] + fn configured_provider_uses_default_approval_review_preferred_model() { + let provider = create_model_provider( + ModelProviderInfo::create_openai_provider(/*base_url*/ None), + /*auth_manager*/ None, + ); + + assert_eq!( + provider.approval_review_preferred_model(), + DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL + ); + } + #[tokio::test] async fn configured_provider_runtime_base_url_uses_configured_base_url() { let provider = create_model_provider( diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 47dc15f183..37e8d95e25 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -465,22 +465,9 @@ pub enum TrustLevel { /// Controls whether the TUI uses the terminal's alternate screen buffer. /// -/// **Background:** The alternate screen buffer provides a cleaner fullscreen experience -/// without polluting the terminal's scrollback history. However, it conflicts with terminal -/// multiplexers like Zellij that strictly follow the xterm specification, which defines -/// that alternate screen buffers should not have scrollback. -/// -/// **Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode -/// (see https://github.com/zellij-org/zellij/pull/1032) to comply with the xterm spec. This -/// is by design and not configurable in Zellij—there is no option to enable scrollback in -/// alternate screen mode. -/// -/// **Solution:** This setting provides a pragmatic workaround: -/// - `auto` (default): Automatically detect the terminal multiplexer. If running in Zellij, -/// disable alternate screen to preserve scrollback. Enable it everywhere else. -/// - `always`: Always use alternate screen mode (original behavior before this fix). -/// - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback -/// in all multiplexers. +/// - `auto` (default): Use alternate screen mode. +/// - `always`: Always use alternate screen mode. +/// - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback. /// /// The CLI flag `--no-alt-screen` can override this setting at runtime. #[derive( @@ -489,10 +476,10 @@ pub enum TrustLevel { #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum AltScreenMode { - /// Auto-detect: disable alternate screen in Zellij, enable elsewhere. + /// Use alternate screen mode. #[default] Auto, - /// Always use alternate screen (original behavior). + /// Always use alternate screen mode. Always, /// Never use alternate screen (inline mode only). Never, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 5a0fafad94..13a4f6e009 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1253,30 +1253,6 @@ pub struct SearchToolCallParams { pub limit: Option, } -/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec` -/// or `shell`, the `arguments` field should deserialize to this struct. -#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -pub struct ShellToolCallParams { - pub command: Vec, - pub workdir: Option, - - /// This is the maximum time in milliseconds that the command is allowed to run. - #[serde(alias = "timeout")] - pub timeout_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub sandbox_permissions: Option, - /// Suggests a command prefix to persist for future sessions - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub prefix_rule: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub additional_permissions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub justification: Option, -} - /// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the /// `arguments` field should deserialize to this struct. #[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -2545,30 +2521,6 @@ mod tests { Ok(()) } - #[test] - fn deserialize_shell_tool_call_params() -> Result<()> { - let json = r#"{ - "command": ["ls", "-l"], - "workdir": "/tmp", - "timeout": 1000 - }"#; - - let params: ShellToolCallParams = serde_json::from_str(json)?; - assert_eq!( - ShellToolCallParams { - command: vec!["ls".to_string(), "-l".to_string()], - workdir: Some("/tmp".to_string()), - timeout_ms: Some(1000), - sandbox_permissions: None, - prefix_rule: None, - additional_permissions: None, - justification: None, - }, - params - ); - Ok(()) - } - #[test] fn wraps_image_user_input_with_tags() -> Result<()> { let image_url = "data:image/png;base64,abc".to_string(); diff --git a/codex-rs/rollout-trace/src/lib.rs b/codex-rs/rollout-trace/src/lib.rs index 3d9e04f36b..54094f0525 100644 --- a/codex-rs/rollout-trace/src/lib.rs +++ b/codex-rs/rollout-trace/src/lib.rs @@ -10,6 +10,7 @@ mod bundle; mod code_cell; mod compaction; mod inference; +mod mcp; mod model; mod payload; mod protocol_event; @@ -33,6 +34,8 @@ pub use compaction::CompactionTraceContext; pub use inference::InferenceTraceAttempt; /// Shared recorder context for inference attempts within one Codex turn. pub use inference::InferenceTraceContext; +/// Trace-owned MCP execution correlation propagated to bridge request metadata. +pub use mcp::McpCallTraceContext; /// Public reduced trace model returned by replay. pub use model::*; /// Stable identifier for one raw payload inside a rollout bundle. diff --git a/codex-rs/rollout-trace/src/mcp.rs b/codex-rs/rollout-trace/src/mcp.rs new file mode 100644 index 0000000000..b8564e1579 --- /dev/null +++ b/codex-rs/rollout-trace/src/mcp.rs @@ -0,0 +1,99 @@ +//! Hot-path helpers for correlating concrete MCP executions with rollout traces. +//! +//! Core decides when an MCP request is actually going to execute. The trace +//! crate owns the globally unique ID, the trace event that preserves it in the +//! reduced artifact, and the bridge-private MCP request metadata key. + +use crate::McpCallId; +use serde_json::Value as JsonValue; + +const MCP_CALL_ID_META_KEY: &str = "codex_bridge_mcp_call_id"; + +/// No-op capable handle for one concrete MCP backend call. +#[derive(Clone, Debug)] +pub struct McpCallTraceContext { + mcp_call_id: Option, +} + +impl McpCallTraceContext { + /// Builds a context that records nothing and leaves request metadata unchanged. + pub fn disabled() -> Self { + Self { mcp_call_id: None } + } + + /// Builds the trace handle for one concrete MCP execution. + pub(crate) fn enabled(mcp_call_id: McpCallId) -> Self { + Self { + mcp_call_id: Some(mcp_call_id), + } + } + + /// Returns the trace-owned MCP call ID when rollout tracing is enabled. + pub(crate) fn mcp_call_id(&self) -> Option<&str> { + self.mcp_call_id.as_deref() + } + + /// Adds bridge-private MCP correlation metadata to one outgoing request. + pub fn add_request_meta(&self, meta: Option) -> Option { + let Some(mcp_call_id) = self.mcp_call_id() else { + return meta; + }; + + match meta { + Some(JsonValue::Object(mut map)) => { + map.insert( + MCP_CALL_ID_META_KEY.to_string(), + JsonValue::String(mcp_call_id.to_string()), + ); + Some(JsonValue::Object(map)) + } + None => { + let mut map = serde_json::Map::new(); + map.insert( + MCP_CALL_ID_META_KEY.to_string(), + JsonValue::String(mcp_call_id.to_string()), + ); + Some(JsonValue::Object(map)) + } + // This should never happen but if it does then we'll fallback to + // a noop rather than any breaking behavior. The tracing is best + // effort after all. + Some(_) => meta, + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::MCP_CALL_ID_META_KEY; + use super::McpCallTraceContext; + + #[test] + fn disabled_mcp_trace_leaves_request_meta_unchanged() { + let meta = Some(json!({"source": "test"})); + + assert_eq!( + McpCallTraceContext::disabled().add_request_meta(meta.clone()), + meta + ); + } + + #[test] + fn enabled_mcp_trace_adds_bridge_correlation_meta() { + let trace = McpCallTraceContext::enabled("mcp-call-id".to_string()); + let meta = trace + .add_request_meta(Some(json!({"source": "test"}))) + .expect("enabled trace keeps request metadata"); + let object = meta + .as_object() + .expect("MCP request metadata remains an object"); + + assert_eq!(object["source"], json!("test")); + assert_eq!( + object[MCP_CALL_ID_META_KEY], + json!(trace.mcp_call_id().expect("enabled trace has an ID")) + ); + } +} diff --git a/codex-rs/rollout-trace/src/model/mod.rs b/codex-rs/rollout-trace/src/model/mod.rs index e265e29100..0166121da9 100644 --- a/codex-rs/rollout-trace/src/model/mod.rs +++ b/codex-rs/rollout-trace/src/model/mod.rs @@ -28,6 +28,8 @@ pub type CodexTurnId = String; pub type ConversationItemId = String; /// Local ID for one outbound upstream inference request. pub type InferenceCallId = String; +/// Globally unique ID for one concrete MCP backend request. +pub type McpCallId = String; /// Reducer-owned ID for one runtime tool-call object. pub type ToolCallId = String; /// Responses `call_id` / custom-tool call ID visible in inference payloads. diff --git a/codex-rs/rollout-trace/src/model/runtime.rs b/codex-rs/rollout-trace/src/model/runtime.rs index a2afcb0636..f0d1d1caf1 100644 --- a/codex-rs/rollout-trace/src/model/runtime.rs +++ b/codex-rs/rollout-trace/src/model/runtime.rs @@ -13,6 +13,7 @@ use super::CompactionId; use super::CompactionRequestId; use super::ConversationItemId; use super::EdgeId; +use super::McpCallId; use super::ModelVisibleCallId; use super::TerminalId; use super::TerminalOperationId; @@ -113,6 +114,8 @@ pub struct CompactionRequest { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ToolCall { pub tool_call_id: ToolCallId, + /// Globally unique MCP execution ID, when this tool reached an MCP backend. + pub mcp_call_id: Option, /// Model-visible protocol call ID, if the model directly requested this tool. pub model_visible_call_id: Option, /// Code-mode runtime's internal tool invocation ID, if this call came from JS. diff --git a/codex-rs/rollout-trace/src/raw_event.rs b/codex-rs/rollout-trace/src/raw_event.rs index d60a2b6c48..bd9010d7bb 100644 --- a/codex-rs/rollout-trace/src/raw_event.rs +++ b/codex-rs/rollout-trace/src/raw_event.rs @@ -8,6 +8,7 @@ use crate::model::CompactionRequestId; use crate::model::EdgeId; use crate::model::ExecutionStatus; use crate::model::InferenceCallId; +use crate::model::McpCallId; use crate::model::ModelVisibleCallId; use crate::model::RolloutStatus; use crate::model::ToolCallId; @@ -138,6 +139,11 @@ pub enum RawTraceEventPayload { summary: ToolCallSummary, invocation_payload: Option, }, + /// Bridge correlation UUID assigned only when a tool reaches an MCP backend. + McpToolCallCorrelationAssigned { + tool_call_id: ToolCallId, + mcp_call_id: McpCallId, + }, ToolCallRuntimeStarted { tool_call_id: ToolCallId, /// Runtime/protocol observation for how Codex began executing the tool. @@ -236,6 +242,7 @@ impl RawTraceEventPayload { | RawTraceEventPayload::CodexTurnEnded { .. } | RawTraceEventPayload::CompactionRequestFailed { .. } | RawTraceEventPayload::CodeCellStarted { .. } + | RawTraceEventPayload::McpToolCallCorrelationAssigned { .. } | RawTraceEventPayload::AgentResultObserved { carried_payload: None, .. diff --git a/codex-rs/rollout-trace/src/reducer/mod.rs b/codex-rs/rollout-trace/src/reducer/mod.rs index 3e8f6f1353..4a908b6e57 100644 --- a/codex-rs/rollout-trace/src/reducer/mod.rs +++ b/codex-rs/rollout-trace/src/reducer/mod.rs @@ -261,6 +261,12 @@ impl TraceReducer { }, )?; } + RawTraceEventPayload::McpToolCallCorrelationAssigned { + tool_call_id, + mcp_call_id, + } => { + self.assign_mcp_tool_call_correlation(tool_call_id, mcp_call_id)?; + } RawTraceEventPayload::ToolCallRuntimeStarted { tool_call_id, runtime_payload, diff --git a/codex-rs/rollout-trace/src/reducer/tool.rs b/codex-rs/rollout-trace/src/reducer/tool.rs index d481bb6acd..43845ea939 100644 --- a/codex-rs/rollout-trace/src/reducer/tool.rs +++ b/codex-rs/rollout-trace/src/reducer/tool.rs @@ -7,6 +7,7 @@ use crate::model::CodeModeRuntimeToolId; use crate::model::ConversationItemKind; use crate::model::ExecutionStatus; use crate::model::ExecutionWindow; +use crate::model::McpCallId; use crate::model::ModelVisibleCallId; use crate::model::ProducerRef; use crate::model::ToolCall; @@ -128,6 +129,7 @@ impl TraceReducer { tool_call_id.clone(), ToolCall { tool_call_id: tool_call_id.clone(), + mcp_call_id: None, model_visible_call_id, code_mode_runtime_tool_id: started.code_mode_runtime_tool_id, thread_id, @@ -166,6 +168,21 @@ impl TraceReducer { Ok(()) } + /// Attaches the bridge-visible MCP UUID after the generic tool call exists. + pub(super) fn assign_mcp_tool_call_correlation( + &mut self, + tool_call_id: ToolCallId, + mcp_call_id: McpCallId, + ) -> Result<()> { + let Some(tool_call) = self.rollout.tool_calls.get_mut(&tool_call_id) else { + bail!("MCP correlation referenced unknown tool call {tool_call_id}"); + }; + if tool_call.mcp_call_id.replace(mcp_call_id).is_some() { + bail!("duplicate MCP correlation for tool call {tool_call_id}"); + } + Ok(()) + } + /// Completes the canonical tool call and any terminal operation driven by dispatch output. /// /// Protocol-backed terminal tools end from runtime events; direct tools diff --git a/codex-rs/rollout-trace/src/thread.rs b/codex-rs/rollout-trace/src/thread.rs index 47526807a1..514b8d382b 100644 --- a/codex-rs/rollout-trace/src/thread.rs +++ b/codex-rs/rollout-trace/src/thread.rs @@ -22,11 +22,13 @@ use crate::CodexTurnId; use crate::CompactionId; use crate::CompactionTraceContext; use crate::InferenceTraceContext; +use crate::McpCallTraceContext; use crate::RawPayloadKind; use crate::RawPayloadRef; use crate::RawTraceEventContext; use crate::RawTraceEventPayload; use crate::RolloutStatus; +use crate::ToolCallId; use crate::ToolDispatchInvocation; use crate::ToolDispatchTraceContext; use crate::TraceWriter; @@ -395,6 +397,24 @@ impl ThreadTraceContext { provider_name.into(), ) } + + /// Starts bridge correlation for one concrete MCP backend request. + /// + /// Dispatch-level tool IDs remain compact and UI-friendly. This UUID is + /// deliberately separate: it is only for cross-process log joins where a + /// rollout-local counter would collide across samples. + pub fn start_mcp_call_trace(&self, tool_call_id: impl Into) -> McpCallTraceContext { + let ThreadTraceContextState::Enabled(context) = &self.state else { + return McpCallTraceContext::disabled(); + }; + let mcp_call_id = Uuid::new_v4().to_string(); + let trace = McpCallTraceContext::enabled(mcp_call_id.clone()); + context.append_best_effort(RawTraceEventPayload::McpToolCallCorrelationAssigned { + tool_call_id: tool_call_id.into(), + mcp_call_id, + }); + trace + } } fn start_root_in_root( diff --git a/codex-rs/rollout/src/lib.rs b/codex-rs/rollout/src/lib.rs index d594292bc1..897c6d8fd3 100644 --- a/codex-rs/rollout/src/lib.rs +++ b/codex-rs/rollout/src/lib.rs @@ -55,6 +55,7 @@ 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::persisted_rollout_items; pub use policy::should_persist_response_item_for_memories; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 271d8ce13a..9c42f8db61 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -1,6 +1,9 @@ use crate::protocol::EventMsg; use crate::protocol::RolloutItem; use codex_protocol::models::ResponseItem; +use codex_utils_string::truncate_middle_chars; + +const PERSISTED_EXEC_AGGREGATED_OUTPUT_MAX_BYTES: usize = 10_000; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum EventPersistenceMode { @@ -22,6 +25,43 @@ pub fn is_persisted_rollout_item(item: &RolloutItem, mode: EventPersistenceMode) } } +/// Return the canonical rollout items that should be persisted for a live append. +pub fn persisted_rollout_items( + items: &[RolloutItem], + mode: EventPersistenceMode, +) -> Vec { + let mut persisted = Vec::new(); + for item in items { + if is_persisted_rollout_item(item, mode) { + persisted.push(sanitize_rollout_item_for_persistence(item.clone(), mode)); + } + } + persisted +} + +fn sanitize_rollout_item_for_persistence( + item: RolloutItem, + mode: EventPersistenceMode, +) -> RolloutItem { + if mode != EventPersistenceMode::Extended { + return item; + } + + match item { + RolloutItem::EventMsg(EventMsg::ExecCommandEnd(mut event)) => { + event.aggregated_output = truncate_middle_chars( + &event.aggregated_output, + PERSISTED_EXEC_AGGREGATED_OUTPUT_MAX_BYTES, + ); + event.stdout.clear(); + event.stderr.clear(); + event.formatted_output.clear(); + RolloutItem::EventMsg(EventMsg::ExecCommandEnd(event)) + } + _ => item, + } +} + /// Whether a `ResponseItem` should be persisted in rollout files. #[inline] pub fn should_persist_response_item(item: &ResponseItem) -> bool { diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index b4e0ab1e09..23b05b9def 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -8,16 +8,11 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; -use std::time::Duration; -use std::time::Instant; -use chrono::DateTime; use chrono::SecondsFormat; -use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; -use codex_utils_string::truncate_middle_chars; use serde_json::Value; use time::OffsetDateTime; use time::format_description::FormatItem; @@ -47,15 +42,13 @@ use super::list::get_threads_in_root; 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_rollout_item; use super::session_index::find_thread_names_by_ids; use crate::config::RolloutConfigView; use crate::default_client::originator; use crate::state_db; use crate::state_db::StateDbHandle; use codex_git_utils::collect_git_info; -use codex_protocol::protocol::EventMsg; +use codex_git_utils::get_git_repo_root; use codex_protocol::protocol::GitInfo as ProtocolGitInfo; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::ResumedHistory; @@ -66,11 +59,9 @@ use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadSource; use codex_state::StateRuntime; -use codex_state::ThreadMetadataBuilder; use codex_utils_path as path_utils; -/// Records all [`ResponseItem`]s for a session and flushes them to disk after -/// every update. +/// Writes canonical session rollout items to JSONL. /// /// Rollouts are recorded as JSONL and can be inspected with tools such as: /// @@ -83,7 +74,6 @@ pub struct RolloutRecorder { tx: Sender, writer_task: Arc, pub(crate) rollout_path: PathBuf, - event_persistence_mode: EventPersistenceMode, } #[derive(Clone)] @@ -95,11 +85,9 @@ pub enum RolloutRecorderParams { thread_source: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, - event_persistence_mode: EventPersistenceMode, }, Resume { path: PathBuf, - event_persistence_mode: EventPersistenceMode, }, } @@ -172,7 +160,6 @@ impl RolloutRecorderParams { thread_source: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, - event_persistence_mode: EventPersistenceMode, ) -> Self { Self::Create { conversation_id, @@ -181,42 +168,11 @@ impl RolloutRecorderParams { thread_source, base_instructions, dynamic_tools, - event_persistence_mode, } } - pub fn resume(path: PathBuf, event_persistence_mode: EventPersistenceMode) -> Self { - Self::Resume { - path, - event_persistence_mode, - } - } -} - -const PERSISTED_EXEC_AGGREGATED_OUTPUT_MAX_BYTES: usize = 10_000; - -fn sanitize_rollout_item_for_persistence( - item: RolloutItem, - mode: EventPersistenceMode, -) -> RolloutItem { - if mode != EventPersistenceMode::Extended { - return item; - } - - match item { - RolloutItem::EventMsg(EventMsg::ExecCommandEnd(mut event)) => { - // Persist only a bounded aggregated summary of command output. - event.aggregated_output = truncate_middle_chars( - &event.aggregated_output, - PERSISTED_EXEC_AGGREGATED_OUTPUT_MAX_BYTES, - ); - // Drop unnecessary fields from rollout storage since aggregated_output is all we need. - event.stdout.clear(); - event.stderr.clear(); - event.formatted_output.clear(); - RolloutItem::EventMsg(EventMsg::ExecCommandEnd(event)) - } - _ => item, + pub fn resume(path: PathBuf) -> Self { + Self::Resume { path } } } @@ -691,80 +647,65 @@ impl RolloutRecorder { pub async fn new( config: &impl RolloutConfigView, params: RolloutRecorderParams, - state_db_ctx: Option, - state_builder: Option, ) -> std::io::Result { - let (file, deferred_log_file_info, rollout_path, meta, event_persistence_mode) = - match params { - RolloutRecorderParams::Create { - conversation_id, + let (file, deferred_log_file_info, rollout_path, meta) = match params { + RolloutRecorderParams::Create { + conversation_id, + forked_from_id, + source, + thread_source, + base_instructions, + dynamic_tools, + } => { + let log_file_info = precompute_log_file_info(config, conversation_id)?; + let path = log_file_info.path.clone(); + let session_id = log_file_info.conversation_id; + let started_at = log_file_info.timestamp; + + let timestamp_format: &[FormatItem] = format_description!( + "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z" + ); + let timestamp = started_at + .to_offset(time::UtcOffset::UTC) + .format(timestamp_format) + .map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?; + + let session_meta = SessionMeta { + id: session_id, forked_from_id, + timestamp, + cwd: config.cwd().to_path_buf(), + originator: originator().value, + cli_version: env!("CARGO_PKG_VERSION").to_string(), + agent_nickname: source.get_nickname(), + agent_role: source.get_agent_role(), + agent_path: source.get_agent_path().map(Into::into), source, thread_source, - base_instructions, - dynamic_tools, - event_persistence_mode, - } => { - let log_file_info = precompute_log_file_info(config, conversation_id)?; - let path = log_file_info.path.clone(); - let session_id = log_file_info.conversation_id; - let started_at = log_file_info.timestamp; + model_provider: Some(config.model_provider_id().to_string()), + base_instructions: Some(base_instructions), + dynamic_tools: if dynamic_tools.is_empty() { + None + } else { + Some(dynamic_tools) + }, + memory_mode: (!config.generate_memories()).then_some("disabled".to_string()), + }; - let timestamp_format: &[FormatItem] = format_description!( - "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z" - ); - let timestamp = started_at - .to_offset(time::UtcOffset::UTC) - .format(timestamp_format) - .map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?; - - let session_meta = SessionMeta { - id: session_id, - forked_from_id, - timestamp, - cwd: config.cwd().to_path_buf(), - originator: originator().value, - cli_version: env!("CARGO_PKG_VERSION").to_string(), - agent_nickname: source.get_nickname(), - agent_role: source.get_agent_role(), - agent_path: source.get_agent_path().map(Into::into), - source, - thread_source, - model_provider: Some(config.model_provider_id().to_string()), - base_instructions: Some(base_instructions), - dynamic_tools: if dynamic_tools.is_empty() { - None - } else { - Some(dynamic_tools) - }, - memory_mode: (!config.generate_memories()) - .then_some("disabled".to_string()), - }; - - ( - None, - Some(log_file_info), - path, - Some(session_meta), - event_persistence_mode, - ) - } - RolloutRecorderParams::Resume { - path, - event_persistence_mode, - } => ( - Some( - tokio::fs::OpenOptions::new() - .append(true) - .open(&path) - .await?, - ), - None, - path, - None, - event_persistence_mode, + (None, Some(log_file_info), path, Some(session_meta)) + } + RolloutRecorderParams::Resume { path } => ( + Some( + tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await?, ), - }; + None, + path, + None, + ), + }; // Clone the cwd for the spawned task to collect git info asynchronously let cwd = config.cwd().to_path_buf(); @@ -779,9 +720,6 @@ impl RolloutRecorder { let writer_task = Arc::new(RolloutWriterTask::new()); let writer_task_for_spawn = Arc::clone(&writer_task); let rollout_path_for_spawn = rollout_path.clone(); - let default_provider = config.model_provider_id().to_string(); - let generate_memories = config.generate_memories(); - let state_db_ctx_for_spawn = state_db_ctx.clone(); let handle = tokio::task::spawn(async move { let result = rollout_writer( file, @@ -790,10 +728,6 @@ impl RolloutRecorder { meta, cwd, rollout_path_for_spawn.clone(), - state_db_ctx_for_spawn, - state_builder, - default_provider, - generate_memories, ) .await; if let Err(err) = result { @@ -812,7 +746,6 @@ impl RolloutRecorder { tx, writer_task, rollout_path, - event_persistence_mode, }) } @@ -820,24 +753,12 @@ impl RolloutRecorder { self.rollout_path.as_path() } - 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_rollout_item(item, self.event_persistence_mode) { - filtered.push(sanitize_rollout_item_for_persistence( - item.clone(), - self.event_persistence_mode, - )); - } - } - if filtered.is_empty() { + pub async fn record_canonical_items(&self, items: &[RolloutItem]) -> std::io::Result<()> { + if items.is_empty() { return Ok(()); } self.tx - .send(RolloutCmd::AddItems(filtered)) + .send(RolloutCmd::AddItems(items.to_vec())) .await .map_err(|e| { self.writer_task.terminal_failure().unwrap_or_else(|| { @@ -1459,48 +1380,17 @@ struct RolloutWriterState { meta: Option, cwd: PathBuf, rollout_path: PathBuf, - state_db_ctx: Option, - state_builder: Option, - default_provider: String, - generate_memories: bool, - thread_updated_at_touch: ThreadUpdatedAtTouch, last_logged_error: Option, } -#[cfg(not(test))] -const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_secs(5); -#[cfg(test)] -const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_millis(50); - -#[derive(Default)] -struct ThreadUpdatedAtTouch { - last_persisted_at: Option, - pending_touch: Option<(ThreadId, DateTime)>, -} - -impl ThreadUpdatedAtTouch { - fn mark_persisted(&mut self, now: Instant) { - self.last_persisted_at = Some(now); - self.pending_touch = None; - } -} - impl RolloutWriterState { - #[allow(clippy::too_many_arguments)] fn new( file: Option, deferred_log_file_info: Option, meta: Option, cwd: PathBuf, rollout_path: PathBuf, - state_db_ctx: Option, - mut state_builder: Option, - default_provider: String, - generate_memories: bool, ) -> Self { - if let Some(builder) = state_builder.as_mut() { - builder.rollout_path = rollout_path.clone(); - } Self { writer: file.map(|file| JsonlWriter { file }), deferred_log_file_info, @@ -1508,11 +1398,6 @@ impl RolloutWriterState { meta, cwd, rollout_path, - state_db_ctx, - state_builder, - default_provider, - generate_memories, - thread_updated_at_touch: ThreadUpdatedAtTouch::default(), last_logged_error: None, } } @@ -1545,19 +1430,7 @@ impl RolloutWriterState { if self.is_deferred() && self.pending_items.is_empty() { return Ok(()); } - self.write_pending_with_recovery("shutdown").await?; - if let Some((thread_id, updated_at)) = self.thread_updated_at_touch.pending_touch.take() - && state_db::touch_thread_updated_at( - self.state_db_ctx.as_deref(), - Some(thread_id), - updated_at, - "rollout_writer_shutdown", - ) - .await - { - self.thread_updated_at_touch.mark_persisted(Instant::now()); - } - Ok(()) + self.write_pending_with_recovery("shutdown").await } async fn write_pending_with_recovery(&mut self, operation: &str) -> std::io::Result<()> { @@ -1625,18 +1498,7 @@ impl RolloutWriterState { let Some(session_meta) = self.meta.as_ref().cloned() else { return Ok(()); }; - write_session_meta( - self.writer.as_mut(), - session_meta, - &self.cwd, - &self.rollout_path, - self.state_db_ctx.as_deref(), - &mut self.state_builder, - self.default_provider.as_str(), - self.generate_memories, - &mut self.thread_updated_at_touch, - ) - .await?; + write_session_meta(self.writer.as_mut(), session_meta, &self.cwd).await?; self.meta = None; Ok(()) } @@ -1669,25 +1531,13 @@ impl RolloutWriterState { } if written_count > 0 { - let written_items: Vec = - self.pending_items.drain(..written_count).collect(); - sync_thread_state_after_write( - self.state_db_ctx.as_deref(), - &self.rollout_path, - self.state_builder.as_ref(), - written_items.as_slice(), - self.default_provider.as_str(), - /*new_thread_memory_mode*/ None, - &mut self.thread_updated_at_touch, - ) - .await; + self.pending_items.drain(..written_count); } write_result } } -#[allow(clippy::too_many_arguments)] async fn rollout_writer( file: Option, deferred_log_file_info: Option, @@ -1695,22 +1545,8 @@ async fn rollout_writer( meta: Option, cwd: PathBuf, rollout_path: PathBuf, - state_db_ctx: Option, - state_builder: Option, - default_provider: String, - generate_memories: bool, ) -> std::io::Result<()> { - let mut state = RolloutWriterState::new( - file, - deferred_log_file_info, - meta, - cwd, - rollout_path, - state_db_ctx, - state_builder, - default_provider, - generate_memories, - ); + let mut state = RolloutWriterState::new(file, deferred_log_file_info, meta, cwd, rollout_path); // Process rollout commands while let Some(cmd) = rx.recv().await { @@ -1740,116 +1576,36 @@ async fn rollout_writer( Ok(()) } -#[allow(clippy::too_many_arguments)] async fn write_session_meta( mut writer: Option<&mut JsonlWriter>, session_meta: SessionMeta, cwd: &Path, - rollout_path: &Path, - state_db_ctx: Option<&StateRuntime>, - state_builder: &mut Option, - default_provider: &str, - generate_memories: bool, - thread_updated_at_touch: &mut ThreadUpdatedAtTouch, ) -> std::io::Result<()> { - let git_info = collect_git_info(cwd).await.map(|info| ProtocolGitInfo { - commit_hash: info.commit_hash, - branch: info.branch, - repository_url: info.repository_url, - }); + let git_info = if get_git_repo_root(cwd).is_some() { + collect_git_info(cwd).await.map(|info| ProtocolGitInfo { + commit_hash: info.commit_hash, + branch: info.branch, + repository_url: info.repository_url, + }) + } else { + None + }; let session_meta_line = SessionMetaLine { meta: session_meta, git: git_info, }; - if state_db_ctx.is_some() { - *state_builder = metadata::builder_from_session_meta(&session_meta_line, rollout_path); - } let rollout_item = RolloutItem::SessionMeta(session_meta_line); if let Some(writer) = writer.as_mut() { writer.write_rollout_item(&rollout_item).await?; } - sync_thread_state_after_write( - state_db_ctx, - rollout_path, - state_builder.as_ref(), - std::slice::from_ref(&rollout_item), - default_provider, - (!generate_memories).then_some("disabled"), - thread_updated_at_touch, - ) - .await; Ok(()) } -async fn sync_thread_state_after_write( - state_db_ctx: Option<&StateRuntime>, - rollout_path: &Path, - state_builder: Option<&ThreadMetadataBuilder>, - items: &[RolloutItem], - default_provider: &str, - new_thread_memory_mode: Option<&str>, - thread_updated_at_touch: &mut ThreadUpdatedAtTouch, -) { - let updated_at = Utc::now(); - let now = Instant::now(); - if new_thread_memory_mode.is_some() - || items - .iter() - .any(codex_state::rollout_item_affects_thread_metadata) - { - state_db::apply_rollout_items( - state_db_ctx, - rollout_path, - default_provider, - state_builder, - items, - "rollout_writer", - new_thread_memory_mode, - Some(updated_at), - ) - .await; - thread_updated_at_touch.mark_persisted(now); - return; - } - - let thread_id = state_builder - .map(|builder| builder.id) - .or_else(|| metadata::builder_from_items(items, rollout_path).map(|builder| builder.id)); - if thread_updated_at_touch - .last_persisted_at - .is_some_and(|last_persisted_at| { - now.duration_since(last_persisted_at) < THREAD_UPDATED_AT_TOUCH_INTERVAL - }) - { - thread_updated_at_touch.pending_touch = thread_id.map(|thread_id| (thread_id, updated_at)); - return; - } - - if state_db::touch_thread_updated_at(state_db_ctx, thread_id, updated_at, "rollout_writer") - .await - { - thread_updated_at_touch.mark_persisted(now); - return; - } - state_db::apply_rollout_items( - state_db_ctx, - rollout_path, - default_provider, - state_builder, - items, - "rollout_writer", - new_thread_memory_mode, - Some(updated_at), - ) - .await; - thread_updated_at_touch.mark_persisted(now); -} - /// Append one already-filtered rollout item to an existing rollout JSONL file. /// /// This is for metadata updates to unloaded threads. Live sessions should use -/// `RolloutRecorder::record_items` so rollout and SQLite updates remain ordered +/// `RolloutRecorder::record_canonical_items` so rollout writes remain ordered /// with the rest of the session stream. pub async fn append_rollout_item_to_path( rollout_path: &Path, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 2063020be2..ede7f720a9 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -372,10 +372,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< /*thread_source*/ None, BaseInstructions::default(), Vec::new(), - EventPersistenceMode::Limited, ), - /*state_db_ctx*/ None, - /*state_builder*/ None, ) .await?; @@ -386,7 +383,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< ); recorder - .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( + .record_canonical_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( AgentMessageEvent { message: "buffered-event".to_string(), phase: None, @@ -401,7 +398,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< ); recorder - .record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( + .record_canonical_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( UserMessageEvent { message: "first-user-message".to_string(), images: None, @@ -453,16 +450,13 @@ async fn persist_reports_filesystem_error_and_retries_buffered_items() -> std::i /*thread_source*/ None, BaseInstructions::default(), Vec::new(), - EventPersistenceMode::Limited, ), - /*state_db_ctx*/ None, - /*state_builder*/ None, ) .await?; let rollout_path = recorder.rollout_path().to_path_buf(); recorder - .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( + .record_canonical_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( AgentMessageEvent { message: "buffered-before-persist".to_string(), phase: None, @@ -498,7 +492,6 @@ async fn persist_reports_filesystem_error_and_retries_buffered_items() -> std::i #[tokio::test] async fn writer_state_retries_write_error_before_reporting_flush_success() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let config = test_config(home.path()); let rollout_path = home.path().join("rollout.jsonl"); File::create(&rollout_path)?; let read_only_file = std::fs::OpenOptions::new().read(true).open(&rollout_path)?; @@ -508,10 +501,6 @@ async fn writer_state_retries_write_error_before_reporting_flush_success() -> st /*meta*/ None, home.path().to_path_buf(), rollout_path.clone(), - /*state_db_ctx*/ None, - /*state_builder*/ None, - config.model_provider_id.clone(), - config.generate_memories, ); state.add_items(vec![RolloutItem::EventMsg(EventMsg::AgentMessage( AgentMessageEvent { @@ -530,216 +519,6 @@ async fn writer_state_retries_write_error_before_reporting_flush_success() -> st Ok(()) } -#[tokio::test] -async fn metadata_irrelevant_events_coalesce_state_db_updated_at() -> std::io::Result<()> { - let home = TempDir::new().expect("temp dir"); - let config = test_config(home.path()); - - let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) - .await - .expect("state db should initialize"); - state_db - .mark_backfill_complete(/*last_watermark*/ None) - .await - .expect("backfill should be complete"); - - let thread_id = ThreadId::new(); - let recorder = RolloutRecorder::new( - &config, - RolloutRecorderParams::new( - thread_id, - /*forked_from_id*/ None, - SessionSource::Cli, - /*thread_source*/ None, - BaseInstructions::default(), - Vec::new(), - EventPersistenceMode::Limited, - ), - Some(state_db.clone()), - /*state_builder*/ None, - ) - .await?; - - recorder - .record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( - UserMessageEvent { - message: "first-user-message".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }, - ))]) - .await?; - recorder.persist().await?; - recorder.flush().await?; - let initial_thread = state_db - .get_thread(thread_id) - .await - .expect("thread should load") - .expect("thread should exist"); - let initial_updated_at = initial_thread.updated_at; - let initial_title = initial_thread.title.clone(); - let initial_first_user_message = initial_thread.first_user_message.clone(); - - recorder - .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( - AgentMessageEvent { - message: "assistant text".to_string(), - phase: None, - memory_citation: None, - }, - ))]) - .await?; - recorder.flush().await?; - - let updated_thread = state_db - .get_thread(thread_id) - .await - .expect("thread should load after agent message") - .expect("thread should still exist"); - - assert_eq!(updated_thread.updated_at, initial_updated_at); - assert_eq!(updated_thread.title, initial_title); - assert_eq!( - updated_thread.first_user_message, - initial_first_user_message - ); - - tokio::time::sleep(THREAD_UPDATED_AT_TOUCH_INTERVAL + Duration::from_millis(10)).await; - - recorder - .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( - AgentMessageEvent { - message: "more assistant text".to_string(), - phase: None, - memory_citation: None, - }, - ))]) - .await?; - recorder.flush().await?; - - let refreshed_thread = state_db - .get_thread(thread_id) - .await - .expect("thread should load after refresh") - .expect("thread should still exist"); - assert!(refreshed_thread.updated_at > initial_updated_at); - assert_eq!(refreshed_thread.title, initial_title); - assert_eq!( - refreshed_thread.first_user_message, - initial_first_user_message - ); - - recorder.shutdown().await?; - Ok(()) -} - -#[tokio::test] -async fn shutdown_flushes_pending_metadata_irrelevant_updated_at() -> std::io::Result<()> { - let home = TempDir::new().expect("temp dir"); - let config = test_config(home.path()); - - let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) - .await - .expect("state db should initialize"); - state_db - .mark_backfill_complete(/*last_watermark*/ None) - .await - .expect("backfill should be complete"); - - let thread_id = ThreadId::new(); - let rollout_path = home.path().join("rollout.jsonl"); - let initial_updated_at = Utc.with_ymd_and_hms(2026, 5, 7, 7, 37, 8).unwrap(); - let builder = ThreadMetadataBuilder::new( - thread_id, - rollout_path.clone(), - initial_updated_at, - SessionSource::Cli, - ); - state_db - .upsert_thread(&builder.build(config.model_provider_id.as_str())) - .await - .expect("thread should be inserted"); - - File::create(&rollout_path)?; - let rollout_file = std::fs::OpenOptions::new() - .append(true) - .open(&rollout_path)?; - let mut state = RolloutWriterState::new( - Some(tokio::fs::File::from_std(rollout_file)), - /*deferred_log_file_info*/ None, - /*meta*/ None, - home.path().to_path_buf(), - rollout_path, - Some(state_db.clone()), - Some(builder), - config.model_provider_id.clone(), - config.generate_memories, - ); - let pending_updated_at = initial_updated_at + chrono::Duration::seconds(1); - state.thread_updated_at_touch.pending_touch = Some((thread_id, pending_updated_at)); - - state.shutdown().await?; - - assert_eq!( - state_db - .get_thread(thread_id) - .await - .expect("thread should load after shutdown") - .expect("thread should still exist") - .updated_at, - pending_updated_at - ); - Ok(()) -} - -#[tokio::test] -async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> std::io::Result<()> -{ - let home = TempDir::new().expect("temp dir"); - let config = test_config(home.path()); - - let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) - .await - .expect("state db should initialize"); - let thread_id = ThreadId::new(); - let rollout_path = home.path().join("rollout.jsonl"); - let builder = ThreadMetadataBuilder::new( - thread_id, - rollout_path.clone(), - Utc::now(), - SessionSource::Cli, - ); - let items = vec![RolloutItem::EventMsg(EventMsg::AgentMessage( - AgentMessageEvent { - message: "assistant text".to_string(), - phase: None, - memory_citation: None, - }, - ))]; - - let mut thread_updated_at_touch = ThreadUpdatedAtTouch::default(); - sync_thread_state_after_write( - Some(state_db.as_ref()), - rollout_path.as_path(), - Some(&builder), - items.as_slice(), - config.model_provider_id.as_str(), - /*new_thread_memory_mode*/ None, - &mut thread_updated_at_touch, - ) - .await; - - let thread = state_db - .get_thread(thread_id) - .await - .expect("thread should load after fallback") - .expect("thread should be inserted after fallback"); - assert_eq!(thread.id, thread_id); - - Ok(()) -} - #[tokio::test] async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md index 5c9cb2062e..4a90165c07 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md @@ -151,12 +151,15 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ - Add `policy.products` only when the user explicitly asks for that override. - Keep marketplace `source.path` relative to the selected marketplace root as `./plugins/`. - When the workflow created or updated a marketplace-backed plugin, end the final user-facing - response with these two Markdown links, in this order, and do not add any text after them: - - `[View ](codex://plugins/view-local?marketplacePath=&pluginName=)` - - `[Share ](codex://plugins/share-local?marketplacePath=&pluginName=)` -- Replace the link-label placeholder and both deeplink placeholders with the real normalized plugin - name plus the real absolute marketplace path from the scaffolded plugin. URL-encode query - parameter values when needed. + response with a short Codex app handoff. Say `To view this in the Codex app:` and write + `View ` and `Share ` as Markdown links, not raw + URLs or code spans. +- The View deeplink uses `codex://plugins/?marketplacePath=`. + The Share deeplink uses the same URL with `&mode=share`. +- Replace the placeholders with the real normalized plugin name and absolute `marketplace.json` + path from the scaffolded plugin. URL-encode the path segment and query value when needed. +- Do not add `pluginName` or `hostId` query parameters to these deeplinks. Codex derives both after + the user clicks the link. - Do not emit the `View ` or `Share ` links when no marketplace entry was created or updated. diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 335dd4ac5b..5f9fa813e0 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -163,6 +163,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R let mut config = Config { config_layer_stack: ConfigLayerStack::default(), startup_warnings: Vec::new(), + bypass_hook_trust: false, model, service_tier: None, review_model: None, @@ -191,6 +192,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R guardian_policy_config: None, include_permissions_instructions: false, include_apps_instructions: false, + include_collaboration_mode_instructions: false, include_skill_instructions: false, include_environment_context: false, compact_prompt: None, diff --git a/codex-rs/thread-store/Cargo.toml b/codex-rs/thread-store/Cargo.toml index 0f8e83fe60..c9f7713b4f 100644 --- a/codex-rs/thread-store/Cargo.toml +++ b/codex-rs/thread-store/Cargo.toml @@ -19,6 +19,7 @@ codex-git-utils = { workspace = true } codex-protocol = { workspace = true } codex-rollout = { workspace = true } codex-state = { workspace = true } +codex-utils-path = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/codex-rs/thread-store/README.md b/codex-rs/thread-store/README.md new file mode 100644 index 0000000000..c6a51d3c8b --- /dev/null +++ b/codex-rs/thread-store/README.md @@ -0,0 +1,35 @@ +# Thread Store + +`codex-thread-store` is the storage boundary for Codex threads. It defines the +`ThreadStore` trait plus local and in-memory implementations. Other storage +implementations may live outside this repository. + +## Responsibilities + +- `ThreadStore::append_items` is the raw canonical history append API. It does + not infer metadata from item contents. +- `ThreadStore::update_thread_metadata` is the only thread metadata write API. + It accepts a single literal metadata patch shape, regardless of whether the + caller is applying a user/API mutation or facts derived above the store from + appended history. +- `LiveThread` is the preferred API for active session persistence. It owns a + per-thread metadata sync helper, applies the rollout persistence policy, + appends canonical history, and then sends metadata patches through + `ThreadStore::update_thread_metadata`. +- `ThreadManager` routes metadata mutations for loaded and cold threads through + one entrypoint. Loaded threads use their `LiveThread`; cold threads go + directly to the store. +- `LocalThreadStore` persists history through `codex-rollout` JSONL files and + persists queryable metadata through the SQLite state database when available. + Local explicit metadata mutations also maintain JSONL/name-index compatibility + so reading old or SQLite-less local storage keeps working. +- `RolloutRecorder` is the local JSONL writer. It writes already-canonical + items for `ThreadStore::append_items`; it no longer decides metadata updates + for live thread-store appends. +- `core/session` creates or resumes `LiveThread` handles and does not need to + know whether persistence is backed by local files or another store. + +## Direction + +New metadata observation semantics should live above `ThreadStore`. Stores +persist explicit metadata fields, but raw history appends remain history-only. diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 1483906267..5956b3dc41 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -22,6 +22,7 @@ use crate::ReadThreadParams; use crate::ResumeThreadParams; use crate::StoredThread; use crate::StoredThreadHistory; +use crate::ThreadMetadataPatch; use crate::ThreadPage; use crate::ThreadStore; use crate::ThreadStoreError; @@ -127,6 +128,7 @@ struct InMemoryThreadStoreState { calls: InMemoryThreadStoreCalls, created_threads: HashMap, histories: HashMap>, + metadata_updates: HashMap, names: HashMap>, rollout_paths: HashMap, } @@ -271,9 +273,14 @@ impl ThreadStore for InMemoryThreadStore { ) -> ThreadStoreResult { let mut state = self.state.lock().await; state.calls.update_thread_metadata += 1; - if let Some(name) = params.patch.name { - state.names.insert(params.thread_id, Some(name)); + if let Some(name) = params.patch.name.clone() { + state.names.insert(params.thread_id, name); } + state + .metadata_updates + .entry(params.thread_id) + .or_default() + .merge(params.patch); stored_thread_from_state(&state, params.thread_id, /*include_history*/ false) } @@ -307,6 +314,7 @@ fn stored_thread_from_state( items: history_items.clone(), }); let name = state.names.get(&thread_id).cloned().flatten(); + let metadata = state.metadata_updates.get(&thread_id); let rollout_path = state .rollout_paths .iter() @@ -316,28 +324,65 @@ fn stored_thread_from_state( Ok(StoredThread { thread_id, - rollout_path, + rollout_path: metadata + .and_then(|metadata| metadata.rollout_path.clone()) + .or(rollout_path), forked_from_id: created.forked_from_id, - preview: String::new(), + preview: metadata + .and_then(|metadata| metadata.preview.clone()) + .unwrap_or_default(), name, - model_provider: "test".to_string(), - model: None, - reasoning_effort: None, - created_at: Utc::now(), - updated_at: Utc::now(), + model_provider: metadata + .and_then(|metadata| metadata.model_provider.clone()) + .unwrap_or_else(|| "test".to_string()), + model: metadata.and_then(|metadata| metadata.model.clone()), + reasoning_effort: metadata.and_then(|metadata| metadata.reasoning_effort), + created_at: metadata + .and_then(|metadata| metadata.created_at) + .unwrap_or_else(Utc::now), + updated_at: metadata + .and_then(|metadata| metadata.updated_at) + .unwrap_or_else(Utc::now), archived_at: None, - cwd: PathBuf::new(), - cli_version: "test".to_string(), - source: created.source.clone(), - thread_source: created.thread_source, - agent_nickname: None, - agent_role: None, - agent_path: None, - git_info: None, - approval_mode: AskForApproval::Never, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - token_usage: None, - first_user_message: None, + cwd: metadata + .and_then(|metadata| metadata.cwd.clone()) + .unwrap_or_default(), + cli_version: metadata + .and_then(|metadata| metadata.cli_version.clone()) + .unwrap_or_else(|| "test".to_string()), + source: metadata + .and_then(|metadata| metadata.source.clone()) + .unwrap_or_else(|| created.source.clone()), + thread_source: metadata + .and_then(|metadata| metadata.thread_source) + .unwrap_or(created.thread_source), + agent_nickname: metadata.and_then(|metadata| metadata.agent_nickname.clone().flatten()), + agent_role: metadata.and_then(|metadata| metadata.agent_role.clone().flatten()), + agent_path: metadata.and_then(|metadata| metadata.agent_path.clone().flatten()), + git_info: metadata.and_then(git_info_from_patch), + approval_mode: metadata + .and_then(|metadata| metadata.approval_mode) + .unwrap_or(AskForApproval::Never), + sandbox_policy: metadata + .and_then(|metadata| metadata.sandbox_policy.clone()) + .unwrap_or_else(SandboxPolicy::new_read_only_policy), + token_usage: metadata.and_then(|metadata| metadata.token_usage.clone()), + first_user_message: metadata.and_then(|metadata| metadata.first_user_message.clone()), history, }) } + +fn git_info_from_patch(patch: &ThreadMetadataPatch) -> Option { + let git_info = patch.git_info.as_ref()?; + let sha = git_info.sha.clone().flatten(); + let branch = git_info.branch.clone().flatten(); + let origin_url = git_info.origin_url.clone().flatten(); + if sha.is_none() && branch.is_none() && origin_url.is_none() { + return None; + } + Some(codex_protocol::protocol::GitInfo { + commit_hash: sha.as_deref().map(codex_git_utils::GitSha::new), + branch, + repository_url: origin_url, + }) +} diff --git a/codex-rs/thread-store/src/lib.rs b/codex-rs/thread-store/src/lib.rs index c7f6b4eaed..90c7e469a3 100644 --- a/codex-rs/thread-store/src/lib.rs +++ b/codex-rs/thread-store/src/lib.rs @@ -9,6 +9,7 @@ mod in_memory; mod live_thread; mod local; mod store; +mod thread_metadata_sync; mod types; pub use error::ThreadStoreError; @@ -22,6 +23,7 @@ pub use local::LocalThreadStoreConfig; pub use store::ThreadStore; pub use types::AppendThreadItemsParams; pub use types::ArchiveThreadParams; +pub use types::ClearableField; pub use types::CreateThreadParams; pub use types::GitInfoPatch; pub use types::ItemPage; @@ -29,7 +31,6 @@ pub use types::ListItemsParams; pub use types::ListThreadsParams; pub use types::ListTurnsParams; pub use types::LoadThreadHistoryParams; -pub use types::OptionalStringPatch; pub use types::ReadThreadByRolloutPathParams; pub use types::ReadThreadParams; pub use types::ResumeThreadParams; diff --git a/codex-rs/thread-store/src/live_thread.rs b/codex-rs/thread-store/src/live_thread.rs index ffbe2a5645..06aec3450a 100644 --- a/codex-rs/thread-store/src/live_thread.rs +++ b/codex-rs/thread-store/src/live_thread.rs @@ -4,6 +4,9 @@ use std::sync::Arc; use codex_protocol::ThreadId; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::ThreadMemoryMode; +use codex_rollout::EventPersistenceMode; +use codex_rollout::persisted_rollout_items; +use tokio::sync::Mutex; use tracing::warn; use crate::AppendThreadItemsParams; @@ -14,10 +17,12 @@ use crate::ReadThreadParams; use crate::ResumeThreadParams; use crate::StoredThread; use crate::StoredThreadHistory; +use crate::ThreadEventPersistenceMode; use crate::ThreadMetadataPatch; use crate::ThreadStore; use crate::ThreadStoreResult; use crate::UpdateThreadMetadataParams; +use crate::thread_metadata_sync::ThreadMetadataSync; /// Handle for an active thread's persistence lifecycle. /// @@ -28,6 +33,8 @@ use crate::UpdateThreadMetadataParams; pub struct LiveThread { thread_id: ThreadId, thread_store: Arc, + event_persistence_mode: EventPersistenceMode, + metadata_sync: Arc>, } /// Owns a live thread while session initialization is still fallible. @@ -85,43 +92,96 @@ impl LiveThread { params: CreateThreadParams, ) -> ThreadStoreResult { let thread_id = params.thread_id; + let event_persistence_mode = event_persistence_mode(params.event_persistence_mode); + let metadata_sync = ThreadMetadataSync::for_create(¶ms).await; thread_store.create_thread(params).await?; Ok(Self { thread_id, thread_store, + event_persistence_mode, + metadata_sync: Arc::new(Mutex::new(metadata_sync)), }) } pub async fn resume( thread_store: Arc, - params: ResumeThreadParams, + mut params: ResumeThreadParams, ) -> ThreadStoreResult { let thread_id = params.thread_id; - thread_store.resume_thread(params).await?; + let event_persistence_mode = event_persistence_mode(params.event_persistence_mode); + let should_load_history = params.history.is_none(); + let include_archived = params.include_archived; + thread_store.resume_thread(params.clone()).await?; + if should_load_history { + match thread_store + .load_history(LoadThreadHistoryParams { + thread_id, + include_archived, + }) + .await + { + Ok(history) => params.history = Some(history.items), + Err(err) => { + let _ = thread_store.discard_thread(thread_id).await; + return Err(err); + } + } + } + let metadata_sync = ThreadMetadataSync::for_resume(¶ms); Ok(Self { thread_id, thread_store, + event_persistence_mode, + metadata_sync: Arc::new(Mutex::new(metadata_sync)), }) } pub async fn append_items(&self, items: &[RolloutItem]) -> ThreadStoreResult<()> { + let canonical_items = persisted_rollout_items(items, self.event_persistence_mode); + if canonical_items.is_empty() { + return Ok(()); + } self.thread_store .append_items(AppendThreadItemsParams { thread_id: self.thread_id, - items: items.to_vec(), + items: canonical_items.clone(), }) + .await?; + let update = self + .metadata_sync + .lock() .await + .observe_appended_items(canonical_items.as_slice()); + if let Some(update) = update { + self.thread_store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id: self.thread_id, + patch: update.patch.clone(), + include_archived: true, + }) + .await?; + self.metadata_sync + .lock() + .await + .mark_pending_update_applied(&update); + } + Ok(()) } pub async fn persist(&self) -> ThreadStoreResult<()> { - self.thread_store.persist_thread(self.thread_id).await + self.thread_store.persist_thread(self.thread_id).await?; + self.flush_pending_metadata_update().await } pub async fn flush(&self) -> ThreadStoreResult<()> { - self.thread_store.flush_thread(self.thread_id).await + self.thread_store.flush_thread(self.thread_id).await?; + self.flush_pending_metadata_update_for_existing_history() + .await } pub async fn shutdown(&self) -> ThreadStoreResult<()> { + self.flush_pending_metadata_update_for_existing_history() + .await?; self.thread_store.shutdown_thread(self.thread_id).await } @@ -160,6 +220,7 @@ impl LiveThread { mode: ThreadMemoryMode, include_archived: bool, ) -> ThreadStoreResult<()> { + self.flush_pending_metadata_update().await?; self.thread_store .update_thread_metadata(UpdateThreadMetadataParams { thread_id: self.thread_id, @@ -178,6 +239,7 @@ impl LiveThread { patch: ThreadMetadataPatch, include_archived: bool, ) -> ThreadStoreResult { + self.flush_pending_metadata_update().await?; self.thread_store .update_thread_metadata(UpdateThreadMetadataParams { thread_id: self.thread_id, @@ -203,4 +265,46 @@ impl LiveThread { .await .map(Some) } + + async fn flush_pending_metadata_update(&self) -> ThreadStoreResult<()> { + let update = self.metadata_sync.lock().await.take_pending_update(); + self.apply_pending_metadata_update(update).await + } + + async fn flush_pending_metadata_update_for_existing_history(&self) -> ThreadStoreResult<()> { + let update = self + .metadata_sync + .lock() + .await + .take_pending_update_for_existing_history(); + self.apply_pending_metadata_update(update).await + } + + async fn apply_pending_metadata_update( + &self, + update: Option, + ) -> ThreadStoreResult<()> { + let Some(update) = update else { + return Ok(()); + }; + self.thread_store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id: self.thread_id, + patch: update.patch.clone(), + include_archived: true, + }) + .await?; + self.metadata_sync + .lock() + .await + .mark_pending_update_applied(&update); + Ok(()) + } +} + +fn event_persistence_mode(mode: ThreadEventPersistenceMode) -> EventPersistenceMode { + match mode { + ThreadEventPersistenceMode::Limited => EventPersistenceMode::Limited, + ThreadEventPersistenceMode::Extended => EventPersistenceMode::Extended, + } } diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index d181149406..56882e4cba 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -1,10 +1,8 @@ use super::LocalThreadStore; use crate::CreateThreadParams; -use crate::ThreadEventPersistenceMode; use crate::ThreadStoreError; use crate::ThreadStoreResult; use codex_protocol::protocol::ThreadMemoryMode; -use codex_rollout::EventPersistenceMode; use codex_rollout::RolloutConfig; use codex_rollout::RolloutRecorder; use codex_rollout::RolloutRecorderParams; @@ -27,7 +25,6 @@ pub(super) async fn create_thread( model_provider_id: params.metadata.model_provider.clone(), generate_memories: matches!(params.metadata.memory_mode, ThreadMemoryMode::Enabled), }; - let state_db_ctx = store.state_db().await; let recorder = RolloutRecorder::new( &config, RolloutRecorderParams::new( @@ -37,10 +34,7 @@ pub(super) async fn create_thread( params.thread_source, params.base_instructions, params.dynamic_tools, - event_persistence_mode(params.event_persistence_mode), ), - state_db_ctx, - /*state_builder*/ None, ) .await .map_err(|err| ThreadStoreError::Internal { @@ -49,10 +43,3 @@ pub(super) async fn create_thread( Ok(recorder) } - -pub(super) fn event_persistence_mode(mode: ThreadEventPersistenceMode) -> EventPersistenceMode { - match mode { - ThreadEventPersistenceMode::Limited => EventPersistenceMode::Limited, - ThreadEventPersistenceMode::Extended => EventPersistenceMode::Extended, - } -} diff --git a/codex-rs/thread-store/src/local/live_writer.rs b/codex-rs/thread-store/src/local/live_writer.rs index 643207b59d..9db49af763 100644 --- a/codex-rs/thread-store/src/local/live_writer.rs +++ b/codex-rs/thread-store/src/local/live_writer.rs @@ -5,7 +5,7 @@ use codex_protocol::protocol::ThreadMemoryMode; use codex_rollout::RolloutConfig; use codex_rollout::RolloutRecorder; use codex_rollout::RolloutRecorderParams; -use codex_rollout::builder_from_items; +use tracing::warn; use super::LocalThreadStore; use super::create_thread; @@ -31,8 +31,8 @@ pub(super) async fn resume_thread( params: ResumeThreadParams, ) -> ThreadStoreResult<()> { store.ensure_live_recorder_absent(params.thread_id).await?; - let (rollout_path, history) = match (params.rollout_path, params.history) { - (Some(rollout_path), history) => (rollout_path, history), + let rollout_path = match (params.rollout_path, params.history) { + (Some(rollout_path), _history) => rollout_path, (None, history) => { let thread = super::read_thread::read_thread( store, @@ -43,20 +43,14 @@ pub(super) async fn resume_thread( }, ) .await?; - let rollout_path = thread + + thread .rollout_path .ok_or_else(|| ThreadStoreError::Internal { message: format!("thread {} does not have a rollout path", params.thread_id), - })?; - ( - rollout_path, - history.or_else(|| thread.history.map(|history| history.items)), - ) + })? } }; - let state_builder = history - .as_deref() - .and_then(|items| builder_from_items(items, rollout_path.as_path())); let cwd = params .metadata .cwd @@ -71,20 +65,11 @@ pub(super) async fn resume_thread( model_provider_id: params.metadata.model_provider.clone(), generate_memories: matches!(params.metadata.memory_mode, ThreadMemoryMode::Enabled), }; - let state_db_ctx = store.state_db().await; - let recorder = RolloutRecorder::new( - &config, - RolloutRecorderParams::resume( - rollout_path, - create_thread::event_persistence_mode(params.event_persistence_mode), - ), - state_db_ctx, - state_builder, - ) - .await - .map_err(|err| ThreadStoreError::Internal { - message: format!("failed to resume local thread recorder: {err}"), - })?; + let recorder = RolloutRecorder::new(&config, RolloutRecorderParams::resume(rollout_path)) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to resume local thread recorder: {err}"), + })?; store.insert_live_recorder(params.thread_id, recorder).await } @@ -92,12 +77,14 @@ pub(super) async fn append_items( store: &LocalThreadStore, params: AppendThreadItemsParams, ) -> ThreadStoreResult<()> { - store - .live_recorder(params.thread_id) - .await? - .record_items(params.items.as_slice()) + let recorder = store.live_recorder(params.thread_id).await?; + recorder + .record_canonical_items(params.items.as_slice()) .await - .map_err(thread_store_io_error) + .map_err(thread_store_io_error)?; + // LiveThread applies metadata immediately after append_items returns. Wait for the local + // writer so SQLite never gets ahead of JSONL for accepted live appends. + recorder.flush().await.map_err(thread_store_io_error) } pub(super) async fn persist_thread( @@ -109,7 +96,8 @@ pub(super) async fn persist_thread( .await? .persist() .await - .map_err(thread_store_io_error) + .map_err(thread_store_io_error)?; + sync_materialized_rollout_path(store, thread_id).await } pub(super) async fn flush_thread( @@ -121,7 +109,8 @@ pub(super) async fn flush_thread( .await? .flush() .await - .map_err(thread_store_io_error) + .map_err(thread_store_io_error)?; + sync_materialized_rollout_path(store, thread_id).await } pub(super) async fn shutdown_thread( @@ -130,6 +119,7 @@ pub(super) async fn shutdown_thread( ) -> ThreadStoreResult<()> { let recorder = store.live_recorder(thread_id).await?; recorder.shutdown().await.map_err(thread_store_io_error)?; + sync_materialized_rollout_path(store, thread_id).await?; store.live_recorders.lock().await.remove(&thread_id); Ok(()) } @@ -161,6 +151,49 @@ pub(super) async fn rollout_path( .to_path_buf()) } +async fn sync_materialized_rollout_path( + store: &LocalThreadStore, + thread_id: ThreadId, +) -> ThreadStoreResult<()> { + let rollout_path = rollout_path(store, thread_id).await?; + if !tokio::fs::try_exists(rollout_path.as_path()) + .await + .unwrap_or(false) + { + return Ok(()); + } + let Some(state_db) = store.state_db().await else { + return Ok(()); + }; + let result: ThreadStoreResult<()> = async { + let Some(mut metadata) = + state_db + .get_thread(thread_id) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to read thread metadata for {thread_id}: {err}"), + })? + else { + return Ok(()); + }; + if metadata.rollout_path != rollout_path { + metadata.rollout_path = rollout_path; + state_db + .upsert_thread(&metadata) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to update thread metadata for {thread_id}: {err}"), + })?; + } + Ok(()) + } + .await; + if let Err(err) = result { + warn!("failed to sync materialized rollout path for thread {thread_id}: {err}"); + } + Ok(()) +} + fn thread_store_io_error(err: std::io::Error) -> ThreadStoreError { ThreadStoreError::Internal { message: err.to_string(), diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 07aa5e925f..ab53e12bbe 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -37,6 +37,18 @@ use crate::ThreadStoreResult; use crate::UpdateThreadMetadataParams; /// Local filesystem/SQLite-backed implementation of [`ThreadStore`]. +/// +/// Local storage has two compatibility surfaces. Rollout JSONL files are the +/// durable replay format and remain readable without SQLite, including older +/// files that encode metadata in `SessionMeta` items and name-index entries. +/// The SQLite state DB, when available, is the queryable metadata index used by +/// list/read paths for fast lookup. +/// +/// Live appends still write canonical JSONL history, but append-derived +/// metadata is observed above the store and applied through +/// [`ThreadStore::update_thread_metadata`]. This implementation applies that +/// patch literally to SQLite while keeping the JSONL/name-index compatibility +/// behavior needed for SQLite-less reads, repair, and old local rollout files. #[derive(Clone)] pub struct LocalThreadStore { pub(super) config: LocalThreadStoreConfig, @@ -270,6 +282,8 @@ impl ThreadStore for LocalThreadStore { #[cfg(test)] mod tests { + use std::sync::Arc; + use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; use codex_protocol::protocol::EventMsg; @@ -280,6 +294,7 @@ mod tests { use tempfile::TempDir; use super::*; + use crate::LiveThread; use crate::ThreadEventPersistenceMode; use crate::ThreadPersistenceMetadata; use crate::local::test_support::test_config; @@ -335,6 +350,267 @@ mod tests { ); } + #[tokio::test] + async fn raw_append_items_does_not_update_sqlite_metadata() { + // This pins the ThreadStore contract: raw appends are history-only. Callers that need + // metadata updates must use LiveThread or call update_thread_metadata explicitly. + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + 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, Some(runtime.clone())); + let thread_id = ThreadId::default(); + + store + .create_thread(create_thread_params(thread_id)) + .await + .expect("create live thread"); + store + .append_items(AppendThreadItemsParams { + thread_id, + items: vec![user_message_item("raw append")], + }) + .await + .expect("append raw item"); + store.flush_thread(thread_id).await.expect("flush thread"); + + assert_eq!( + runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read"), + None + ); + } + + #[tokio::test] + async fn live_thread_observes_appended_items_into_sqlite_metadata() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); + let thread_id = ThreadId::default(); + let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id)) + .await + .expect("create live thread"); + + live_thread + .append_items(&[user_message_item("observed append")]) + .await + .expect("append observed item"); + live_thread.flush().await.expect("flush thread"); + + let metadata = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + assert_eq!( + metadata.first_user_message.as_deref(), + Some("observed append") + ); + assert_eq!(metadata.preview.as_deref(), Some("observed append")); + assert_eq!(metadata.title, "observed append"); + } + + #[tokio::test] + async fn live_thread_shutdown_does_not_materialize_empty_thread_metadata() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); + let thread_id = ThreadId::default(); + let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id)) + .await + .expect("create live thread"); + let rollout_path = store + .live_rollout_path(thread_id) + .await + .expect("live rollout path"); + + live_thread.shutdown().await.expect("shutdown thread"); + + assert!( + !tokio::fs::try_exists(rollout_path.as_path()) + .await + .expect("rollout path should be checkable") + ); + assert_eq!( + runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read"), + None + ); + } + + #[tokio::test] + async fn live_thread_shutdown_with_buffered_items_materializes_before_metadata_read() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); + let thread_id = ThreadId::default(); + let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id)) + .await + .expect("create live thread"); + let rollout_path = store + .live_rollout_path(thread_id) + .await + .expect("live rollout path"); + + live_thread + .append_items(&[RolloutItem::EventMsg(EventMsg::TokenCount( + codex_protocol::protocol::TokenCountEvent { + info: None, + rate_limits: None, + }, + ))]) + .await + .expect("append metadata-only item"); + live_thread.shutdown().await.expect("shutdown thread"); + + assert!( + tokio::fs::try_exists(rollout_path.as_path()) + .await + .expect("rollout path should be checkable") + ); + let metadata = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + assert_eq!(metadata.rollout_path, rollout_path); + } + + #[tokio::test] + async fn live_thread_resume_loads_history_before_observing_metadata() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); + let uuid = uuid::Uuid::from_u128(401); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let rollout_path = + write_session_file(home.path(), "2025-01-03T17-00-00", uuid).expect("session file"); + let live_thread = LiveThread::resume( + store, + ResumeThreadParams { + thread_id, + rollout_path: Some(rollout_path), + history: None, + include_archived: false, + metadata: ThreadPersistenceMetadata { + cwd: Some(home.path().to_path_buf()), + model_provider: "different-provider".to_string(), + memory_mode: ThreadMemoryMode::Enabled, + }, + event_persistence_mode: ThreadEventPersistenceMode::Limited, + }, + ) + .await + .expect("resume live thread"); + + live_thread + .append_items(&[user_message_item("new live append")]) + .await + .expect("append after resume"); + + let metadata = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + assert_eq!( + metadata.created_at.to_rfc3339(), + "2025-01-03T17:00:00+00:00" + ); + assert_eq!(metadata.model_provider, "test-provider"); + assert_eq!( + metadata.first_user_message.as_deref(), + Some("Hello from user") + ); + } + + #[tokio::test] + async fn live_thread_resume_loads_history_from_explicit_external_rollout_path() { + let home = TempDir::new().expect("temp dir"); + let external_home = TempDir::new().expect("external temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); + let uuid = uuid::Uuid::from_u128(402); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let rollout_path = write_session_file(external_home.path(), "2025-01-03T17-30-00", uuid) + .expect("external session file"); + let live_thread = LiveThread::resume( + store, + ResumeThreadParams { + thread_id, + rollout_path: Some(rollout_path), + history: None, + include_archived: false, + metadata: ThreadPersistenceMetadata { + cwd: Some(home.path().to_path_buf()), + model_provider: "different-provider".to_string(), + memory_mode: ThreadMemoryMode::Enabled, + }, + event_persistence_mode: ThreadEventPersistenceMode::Limited, + }, + ) + .await + .expect("resume external live thread"); + + live_thread + .append_items(&[user_message_item("new external append")]) + .await + .expect("append after external resume"); + + let metadata = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + assert_eq!( + metadata.created_at.to_rfc3339(), + "2025-01-03T17:30:00+00:00" + ); + assert_eq!(metadata.model_provider, "test-provider"); + assert_eq!( + metadata.first_user_message.as_deref(), + Some("Hello from user") + ); + } + #[tokio::test] async fn create_thread_rejects_missing_cwd() { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 8e93d25ae1..718527e7ec 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -273,7 +273,8 @@ async fn stored_thread_from_sqlite_metadata( None => find_thread_name_by_id(store.config.codex_home.as_path(), &metadata.id) .await .ok() - .flatten(), + .flatten() + .filter(|title| !title.trim().is_empty()), }; let session_meta = read_session_meta_line(metadata.rollout_path.as_path()) .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 ef69cfa8b8..c09ca80f97 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -1,9 +1,11 @@ use std::path::Path; use std::path::PathBuf; +use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode; use codex_rollout::ARCHIVED_SESSIONS_SUBDIR; use codex_rollout::append_rollout_item_to_path; @@ -11,6 +13,7 @@ use codex_rollout::append_thread_name; use codex_rollout::find_archived_thread_path_by_id_str; use codex_rollout::find_thread_path_by_id_str; use codex_rollout::read_session_meta_line; +use codex_state::ThreadMetadataBuilder; use super::LocalThreadStore; use super::helpers::git_info_from_parts; @@ -18,6 +21,7 @@ use super::live_writer; use crate::GitInfoPatch; use crate::ReadThreadParams; use crate::StoredThread; +use crate::ThreadMetadataPatch; use crate::ThreadStoreError; use crate::ThreadStoreResult; use crate::UpdateThreadMetadataParams; @@ -32,25 +36,35 @@ pub(super) async fn update_thread_metadata( store: &LocalThreadStore, params: UpdateThreadMetadataParams, ) -> ThreadStoreResult { - let field_count = usize::from(params.patch.name.is_some()) - + usize::from(params.patch.memory_mode.is_some()) - + usize::from(params.patch.git_info.is_some()); - if field_count > 1 { - return Err(ThreadStoreError::InvalidRequest { - message: "local thread store applies one metadata field per patch in this slice" - .to_string(), - }); + let thread_id = params.thread_id; + let patch = params.patch; + if patch.is_empty() { + return read_thread::read_thread( + store, + ReadThreadParams { + thread_id, + include_archived: params.include_archived, + include_history: false, + }, + ) + .await; + } + + let needs_rollout_compat = needs_rollout_compatibility_update(&patch); + let updated = + apply_metadata_update(store, thread_id, patch.clone(), params.include_archived).await?; + if !needs_rollout_compat { + return Ok(updated); } - let thread_id = params.thread_id; if live_writer::rollout_path(store, thread_id).await.is_ok() { live_writer::persist_thread(store, thread_id).await?; } let resolved_rollout_path = resolve_rollout_path(store, thread_id, params.include_archived).await?; - let name = params.patch.name; - let git_info = params.patch.git_info; - if let Some(memory_mode) = params.patch.memory_mode { + let name = patch.name; + let git_info = patch.git_info; + if let Some(memory_mode) = patch.memory_mode { apply_thread_memory_mode(resolved_rollout_path.path.as_path(), thread_id, memory_mode) .await?; } @@ -68,7 +82,7 @@ pub(super) async fn update_thread_metadata( .await; if let Some(name) = name { - apply_thread_name(store, thread_id, name).await?; + apply_thread_name(store, thread_id, name.unwrap_or_default()).await?; } let resolved_git_info = match git_info { @@ -150,6 +164,218 @@ pub(super) async fn update_thread_metadata( Ok(thread) } +async fn apply_metadata_update( + store: &LocalThreadStore, + thread_id: ThreadId, + patch: ThreadMetadataPatch, + include_archived: bool, +) -> ThreadStoreResult { + let live_rollout_path = live_writer::rollout_path(store, thread_id).await.ok(); + let mut rollout_path = patch.rollout_path.clone().or(live_rollout_path); + let mut rollout_path_archived = rollout_path + .as_deref() + .is_some_and(|path| rollout_path_is_archived(store, path)); + let state_db = store.state_db().await; + let sqlite_write_result: ThreadStoreResult<()> = if let Some(state_db) = state_db.as_ref() { + let patch = patch.clone(); + async { + let existing = + state_db + .get_thread(thread_id) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to read thread metadata for {thread_id}: {err}"), + })?; + if existing.is_none() && rollout_path.is_none() { + let resolved = resolve_rollout_path(store, thread_id, include_archived).await?; + rollout_path_archived = resolved.archived; + rollout_path = Some(resolved.path); + } + let mut metadata = existing.clone().unwrap_or_else(|| { + let created_at = patch + .created_at + .or(patch.updated_at) + .unwrap_or_else(Utc::now); + let mut builder = ThreadMetadataBuilder::new( + thread_id, + rollout_path.clone().unwrap_or_default(), + created_at, + patch.source.clone().unwrap_or(SessionSource::Unknown), + ); + builder.model_provider = patch.model_provider.clone(); + builder.thread_source = patch.thread_source.flatten(); + builder.agent_nickname = patch.agent_nickname.clone().flatten(); + builder.agent_role = patch.agent_role.clone().flatten(); + builder.agent_path = patch.agent_path.clone().flatten(); + builder.cwd = patch.cwd.clone().map(normalize_cwd).unwrap_or_default(); + builder.cli_version = patch.cli_version.clone(); + let mut metadata = builder.build(store.config.default_model_provider_id.as_str()); + if rollout_path_archived { + metadata.archived_at = Some(metadata.updated_at); + } + metadata + }); + if let Some(rollout_path) = rollout_path { + metadata.rollout_path = rollout_path; + } + if let Some(preview) = patch.preview { + metadata.preview = Some(preview); + } + if let Some(name) = patch.name { + metadata.title = name.unwrap_or_default(); + } + if let Some(title) = patch.title { + metadata.title = title; + } + if let Some(model_provider) = patch.model_provider { + metadata.model_provider = model_provider; + } + if let Some(model) = patch.model { + metadata.model = Some(model); + } + if let Some(reasoning_effort) = patch.reasoning_effort { + metadata.reasoning_effort = Some(reasoning_effort); + } + if let Some(created_at) = patch.created_at { + metadata.created_at = created_at; + } + if let Some(updated_at) = patch.updated_at { + metadata.updated_at = updated_at; + } + if let Some(source) = patch.source { + metadata.source = enum_to_string(&source); + } + if let Some(thread_source) = patch.thread_source { + metadata.thread_source = thread_source; + } + if let Some(agent_nickname) = patch.agent_nickname { + metadata.agent_nickname = agent_nickname; + } + if let Some(agent_role) = patch.agent_role { + metadata.agent_role = agent_role; + } + if let Some(agent_path) = patch.agent_path { + metadata.agent_path = agent_path; + } + if let Some(cwd) = patch.cwd { + metadata.cwd = normalize_cwd(cwd); + } + if let Some(cli_version) = patch.cli_version { + metadata.cli_version = cli_version; + } + if let Some(approval_mode) = patch.approval_mode { + metadata.approval_mode = enum_to_string(&approval_mode); + } + if let Some(sandbox_policy) = patch.sandbox_policy { + metadata.sandbox_policy = enum_to_string(&sandbox_policy); + } + if let Some(token_usage) = patch.token_usage { + metadata.tokens_used = token_usage.total_tokens.max(0); + } + if let Some(first_user_message) = patch.first_user_message { + metadata.first_user_message = Some(first_user_message); + } + if let Some(git_info) = patch.git_info { + let existing_git_info = git_info_from_parts( + metadata.git_sha.clone(), + metadata.git_branch.clone(), + metadata.git_origin_url.clone(), + ); + let (sha, branch, origin_url) = resolve_git_info_patch(existing_git_info, git_info); + metadata.git_sha = sha; + metadata.git_branch = branch; + metadata.git_origin_url = origin_url; + } + state_db + .upsert_thread(&metadata) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to update thread metadata for {thread_id}: {err}"), + })?; + if let Some(memory_mode) = patch.memory_mode { + state_db + .set_thread_memory_mode(thread_id, memory_mode_as_str(memory_mode)) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to update memory mode for {thread_id}: {err}"), + })?; + } + if let Some(dynamic_tools) = patch.dynamic_tools { + state_db + .persist_dynamic_tools(thread_id, Some(dynamic_tools.as_slice())) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to update dynamic tools for {thread_id}: {err}"), + })?; + } + Ok(()) + } + .await + } else { + Ok(()) + }; + match (state_db.is_some(), sqlite_write_result) { + (true, Ok(())) => {} + (true, Err(err)) => return Err(err), + (false, Ok(())) => {} + (false, Err(err)) => return Err(err), + } + + read_thread::read_thread( + store, + ReadThreadParams { + thread_id, + include_archived, + include_history: false, + }, + ) + .await +} + +fn needs_rollout_compatibility_update(patch: &ThreadMetadataPatch) -> bool { + if patch.name.is_some() { + return true; + } + if patch.memory_mode.is_none() && patch.git_info.is_none() { + return false; + } + !has_observed_metadata_facts(patch) +} + +fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { + patch.rollout_path.is_some() + || patch.preview.is_some() + || patch.title.is_some() + || patch.model_provider.is_some() + || patch.model.is_some() + || patch.reasoning_effort.is_some() + || patch.created_at.is_some() + || patch.source.is_some() + || patch.thread_source.is_some() + || patch.agent_nickname.is_some() + || patch.agent_role.is_some() + || patch.agent_path.is_some() + || patch.cwd.is_some() + || patch.cli_version.is_some() + || patch.approval_mode.is_some() + || patch.sandbox_policy.is_some() + || patch.token_usage.is_some() + || patch.first_user_message.is_some() + || patch.dynamic_tools.is_some() +} + +fn enum_to_string(value: &T) -> String { + match serde_json::to_value(value) { + Ok(serde_json::Value::String(value)) => value, + Ok(other) => other.to_string(), + Err(_) => String::new(), + } +} + +fn normalize_cwd(cwd: PathBuf) -> PathBuf { + codex_utils_path::normalize_for_path_comparison(cwd.as_path()).unwrap_or(cwd) +} + async fn apply_thread_git_info( store: &LocalThreadStore, thread_id: ThreadId, @@ -363,10 +589,13 @@ mod tests { use super::*; use crate::GitInfoPatch; + use crate::ListThreadsParams; use crate::ResumeThreadParams; + use crate::SortDirection; use crate::ThreadEventPersistenceMode; use crate::ThreadMetadataPatch; use crate::ThreadPersistenceMetadata; + use crate::ThreadSortKey; use crate::ThreadStore; use crate::local::LocalThreadStore; use crate::local::test_support::test_config; @@ -385,7 +614,7 @@ mod tests { .update_thread_metadata(UpdateThreadMetadataParams { thread_id, patch: ThreadMetadataPatch { - name: Some("A sharper name".to_string()), + name: Some(Some("A sharper name".to_string())), ..Default::default() }, include_archived: false, @@ -855,44 +1084,306 @@ mod tests { } #[tokio::test] - async fn update_thread_metadata_rejects_multi_field_patch_without_partial_write() { + async fn update_thread_metadata_applies_combined_explicit_patch() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config, Some(runtime.clone())); let uuid = Uuid::from_u128(305); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let path = write_session_file(home.path(), "2025-01-03T15-30-00", uuid).expect("session file"); - let original = std::fs::read_to_string(&path).expect("read rollout"); - let err = store + let thread = store .update_thread_metadata(UpdateThreadMetadataParams { thread_id, patch: ThreadMetadataPatch { - name: Some("Should not persist".to_string()), + name: Some(Some("Combined metadata".to_string())), memory_mode: Some(ThreadMemoryMode::Disabled), + git_info: Some(GitInfoPatch { + branch: Some(Some("combined".to_string())), + ..Default::default() + }), ..Default::default() }, include_archived: false, }) .await - .expect_err("multi-field patch should fail"); + .expect("combined patch should apply"); - assert!(matches!(err, ThreadStoreError::InvalidRequest { .. })); + assert_eq!(thread.name.as_deref(), Some("Combined metadata")); assert_eq!( - std::fs::read_to_string(&path).expect("read rollout"), - original + thread.git_info.expect("git info").branch.as_deref(), + Some("combined") ); + let appended = last_rollout_item(path.as_path()); + assert_eq!(appended["type"], "session_meta"); + assert_eq!(appended["payload"]["memory_mode"], "disabled"); + assert_eq!(appended["payload"]["git"]["branch"], "combined"); let latest_name = codex_rollout::find_thread_name_by_id(home.path(), &thread_id) .await .expect("find thread name"); - assert_eq!(latest_name, None); + assert_eq!(latest_name.as_deref(), Some("Combined metadata")); + let memory_mode = runtime + .get_thread_memory_mode(thread_id) + .await + .expect("thread memory mode should be readable"); + assert_eq!(memory_mode.as_deref(), Some("disabled")); + } + + #[tokio::test] + async fn metadata_patch_applies_title_over_existing_name() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config, Some(runtime)); + let uuid = Uuid::from_u128(306); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + write_session_file(home.path(), "2025-01-03T15-45-00", uuid).expect("session file"); + + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + name: Some(Some("User chosen name".to_string())), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("set explicit name"); + + let thread = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + title: Some("Derived first message".to_string()), + preview: Some("Derived first message".to_string()), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("apply observed metadata"); + + assert_eq!(thread.name.as_deref(), Some("Derived first message")); + } + + #[tokio::test] + async fn metadata_patch_applies_latest_preview_and_first_user_message() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config, Some(runtime.clone())); + let uuid = Uuid::from_u128(313); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + write_session_file(home.path(), "2025-01-03T19-00-00", uuid).expect("session file"); + + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + preview: Some("Original preview".to_string()), + first_user_message: Some("Original first message".to_string()), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("set observed metadata"); + + let thread = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + preview: Some("Later preview".to_string()), + first_user_message: Some("Later first message".to_string()), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("apply later observed metadata"); + + assert_eq!(thread.preview, "Hello from user"); + assert_eq!( + thread.first_user_message.as_deref(), + Some("Hello from user") + ); + let metadata = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + assert_eq!(metadata.preview.as_deref(), Some("Later preview")); + assert_eq!( + metadata.first_user_message.as_deref(), + Some("Later first message") + ); + } + + #[tokio::test] + async fn observed_metadata_rejects_unknown_thread_without_rollout() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config, Some(runtime.clone())); + let uuid = Uuid::from_u128(314); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + + let err = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + preview: Some("phantom".to_string()), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect_err("metadata-only update should not create a missing thread"); + + assert!(matches!( + err, + ThreadStoreError::InvalidRequest { message } + if message == format!("thread not found: {thread_id}") + )); + let metadata = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read"); + assert!(metadata.is_none()); + } + + #[tokio::test] + async fn update_thread_metadata_recreates_missing_archived_sqlite_row_as_archived() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let uuid = Uuid::from_u128(315); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + write_archived_session_file(home.path(), "2025-01-03T19-30-00", uuid) + .expect("archived session file"); + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config, Some(runtime.clone())); + + let thread = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + preview: Some("Archived missing sqlite row".to_string()), + ..Default::default() + }, + include_archived: true, + }) + .await + .expect("update archived thread without sqlite row"); + + assert!(thread.archived_at.is_some()); + assert!( + runtime + .get_thread(thread_id) + .await + .expect("get metadata") + .expect("metadata") + .archived_at + .is_some() + ); + } + + #[tokio::test] + async fn observed_metadata_normalizes_cwd_for_list_filters() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config, Some(runtime.clone())); + let uuid = Uuid::from_u128(316); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + write_session_file(home.path(), "2025-01-03T20-00-00", uuid).expect("session file"); + let workspace = home.path().join("workspace"); + let child = workspace.join("child"); + std::fs::create_dir_all(child.as_path()).expect("create workspace"); + let unnormalized_cwd = child.join(".."); + let normalized_cwd = codex_utils_path::normalize_for_path_comparison(workspace.as_path()) + .expect("normalize cwd"); + + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + cwd: Some(unnormalized_cwd), + preview: Some("cwd preview".to_string()), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("update observed cwd"); + + let metadata = runtime + .get_thread(thread_id) + .await + .expect("get metadata") + .expect("metadata"); + assert_eq!(metadata.cwd, normalized_cwd); + let page = store + .list_threads(ListThreadsParams { + page_size: 10, + cursor: None, + sort_key: ThreadSortKey::UpdatedAt, + sort_direction: SortDirection::Desc, + allowed_sources: Vec::new(), + model_providers: Some(Vec::new()), + cwd_filters: Some(vec![workspace]), + archived: false, + search_term: None, + use_state_db_only: true, + }) + .await + .expect("list threads by cwd"); + assert_eq!( + page.items + .iter() + .map(|thread| thread.thread_id) + .collect::>(), + vec![thread_id] + ); } #[tokio::test] 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 uuid = Uuid::from_u128(306); + let uuid = Uuid::from_u128(307); 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) .expect("archived session file"); @@ -931,7 +1422,7 @@ mod tests { .update_thread_metadata(UpdateThreadMetadataParams { thread_id, patch: ThreadMetadataPatch { - name: Some("Archived title".to_string()), + name: Some(Some("Archived title".to_string())), ..Default::default() }, include_archived: true, @@ -996,7 +1487,7 @@ mod tests { .update_thread_metadata(UpdateThreadMetadataParams { thread_id, patch: ThreadMetadataPatch { - name: Some("Live archived title".to_string()), + name: Some(Some("Live archived title".to_string())), ..Default::default() }, include_archived: true, diff --git a/codex-rs/thread-store/src/store.rs b/codex-rs/thread-store/src/store.rs index bd5e3e7d3d..7e51195997 100644 --- a/codex-rs/thread-store/src/store.rs +++ b/codex-rs/thread-store/src/store.rs @@ -33,7 +33,11 @@ pub trait ThreadStore: Any + Send + Sync { /// Reopens an existing thread for live appends. async fn resume_thread(&self, params: ResumeThreadParams) -> ThreadStoreResult<()>; - /// Appends items to a live thread. + /// Appends canonical rollout items to a live thread. + /// + /// This is the raw history API. It does not infer metadata from item contents. Callers that + /// need metadata updates should call [`ThreadStore::update_thread_metadata`] with explicit + /// metadata facts prepared above the store. async fn append_items(&self, params: AppendThreadItemsParams) -> ThreadStoreResult<()>; /// Materializes the thread if persistence is lazy, then persists all queued items. @@ -86,7 +90,10 @@ pub trait ThreadStore: Any + Send + Sync { }) } - /// Applies a mutable metadata patch and returns the updated thread. + /// Applies a literal metadata patch and returns the updated thread. + /// + /// Implementations should apply the supplied fields directly. Policy such as deciding whether + /// an append-derived preview should be emitted belongs above the store. async fn update_thread_metadata( &self, params: UpdateThreadMetadataParams, diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs new file mode 100644 index 0000000000..37afda8bc2 --- /dev/null +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -0,0 +1,580 @@ +use std::time::Duration; +use std::time::Instant; + +use chrono::DateTime; +use chrono::NaiveDateTime; +use chrono::Utc; +use codex_git_utils::collect_git_info; +use codex_git_utils::get_git_repo_root; +use codex_protocol::ThreadId; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::GitInfo; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::ThreadMemoryMode; +use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use codex_protocol::protocol::UserMessageEvent; + +use crate::CreateThreadParams; +use crate::GitInfoPatch; +use crate::ResumeThreadParams; +use crate::ThreadMetadataPatch; + +const IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER: &str = "[Image]"; +#[cfg(not(test))] +const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_secs(5); +#[cfg(test)] +const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_millis(50); + +/// Live-thread helper that derives metadata updates from canonical rollout items. +/// +/// Stores receive raw history plus explicit metadata patches. This helper keeps append-derived +/// metadata observation in the live layer without owning persistence-policy filtering or making +/// `append_items` infer metadata inside a `ThreadStore` implementation. +pub(crate) struct ThreadMetadataSync { + thread_id: ThreadId, + cwd_seen: bool, + preview_seen: bool, + first_user_message_seen: bool, + title_seen: bool, + pending_update: Option, + pending_update_generation: u64, + last_touch_persisted_at: Option, + defer_create_update_until_history_exists: bool, + defer_resume_update_until_append: bool, +} + +pub(crate) struct PendingThreadMetadataPatch { + pub(crate) patch: ThreadMetadataPatch, + generation: u64, +} + +impl ThreadMetadataSync { + pub(crate) async fn for_create(params: &CreateThreadParams) -> Self { + let created_at = Utc::now(); + let cwd = params.metadata.cwd.clone().unwrap_or_default(); + let git_info = if get_git_repo_root(cwd.as_path()).is_some() { + collect_git_info(cwd.as_path()).await.map(|info| GitInfo { + commit_hash: info.commit_hash, + branch: info.branch, + repository_url: info.repository_url, + }) + } else { + None + }; + let dynamic_tools = + (!params.dynamic_tools.is_empty()).then(|| params.dynamic_tools.clone()); + let update = ThreadMetadataPatch { + model_provider: Some(params.metadata.model_provider.clone()), + created_at: Some(created_at), + updated_at: Some(created_at), + source: Some(params.source.clone()), + thread_source: Some(params.thread_source), + agent_nickname: Some(params.source.get_nickname()), + agent_role: Some(params.source.get_agent_role()), + agent_path: Some(params.source.get_agent_path().map(Into::into)), + cwd: Some(cwd.clone()), + cli_version: Some(env!("CARGO_PKG_VERSION").to_string()), + git_info: git_info.map(git_info_patch_from_observation), + memory_mode: Some(params.metadata.memory_mode), + dynamic_tools, + ..Default::default() + }; + Self { + thread_id: params.thread_id, + cwd_seen: !cwd.as_os_str().is_empty(), + preview_seen: false, + first_user_message_seen: false, + title_seen: false, + pending_update: Some(update), + pending_update_generation: 1, + last_touch_persisted_at: None, + defer_create_update_until_history_exists: true, + defer_resume_update_until_append: false, + } + } + + pub(crate) fn for_resume(params: &ResumeThreadParams) -> Self { + let mut sync = Self { + thread_id: params.thread_id, + cwd_seen: params + .metadata + .cwd + .as_ref() + .is_some_and(|cwd| !cwd.as_os_str().is_empty()), + preview_seen: false, + first_user_message_seen: false, + title_seen: false, + pending_update: None, + pending_update_generation: 0, + last_touch_persisted_at: None, + defer_create_update_until_history_exists: false, + defer_resume_update_until_append: false, + }; + if let Some(history) = params.history.as_deref() { + let update = sync.observe_resume_history(history); + sync.merge_pending_update(update); + sync.defer_resume_update_until_append = sync.pending_update.is_some(); + } + sync + } + + pub(crate) fn take_pending_update(&self) -> Option { + self.pending_update + .clone() + .map(|patch| PendingThreadMetadataPatch { + patch, + generation: self.pending_update_generation, + }) + } + + pub(crate) fn take_pending_update_for_existing_history( + &self, + ) -> Option { + if self.defer_create_update_until_history_exists { + return None; + } + if self.defer_resume_update_until_append { + return None; + } + self.take_pending_update() + } + + pub(crate) fn mark_pending_update_applied(&mut self, update: &PendingThreadMetadataPatch) { + if self.pending_update_generation == update.generation { + self.pending_update = None; + } + if update.patch.updated_at.is_some() { + self.last_touch_persisted_at = Some(Instant::now()); + } + } + + pub(crate) fn observe_appended_items( + &mut self, + items: &[RolloutItem], + ) -> Option { + self.defer_create_update_until_history_exists = false; + self.defer_resume_update_until_append = false; + let affects_metadata = items + .iter() + .any(codex_state::rollout_item_affects_thread_metadata); + let update = if affects_metadata { + self.observe_items(items)? + } else { + thread_updated_at_touch() + }; + self.merge_pending_update(Some(update)); + if !affects_metadata + && !self + .pending_update + .as_ref() + .is_some_and(update_has_metadata_facts) + && self.last_touch_persisted_at.is_some_and(|last_touch| { + Instant::now().duration_since(last_touch) < THREAD_UPDATED_AT_TOUCH_INTERVAL + }) + { + return None; + } + self.take_pending_update() + } + + fn observe_items(&mut self, items: &[RolloutItem]) -> Option { + self.observe_items_with_update( + items, + ThreadMetadataPatch { + updated_at: Some(Utc::now()), + ..Default::default() + }, + ) + } + + fn observe_resume_history(&mut self, items: &[RolloutItem]) -> Option { + self.observe_items_with_update(items, ThreadMetadataPatch::default()) + } + + fn observe_items_with_update( + &mut self, + items: &[RolloutItem], + mut update: ThreadMetadataPatch, + ) -> Option { + if items.is_empty() { + return None; + } + for item in items { + match item { + RolloutItem::SessionMeta(meta_line) if meta_line.meta.id == self.thread_id => { + update.created_at = parse_session_timestamp(meta_line.meta.timestamp.as_str()); + update.source = Some(meta_line.meta.source.clone()); + update.thread_source = Some(meta_line.meta.thread_source); + update.agent_nickname = Some(meta_line.meta.agent_nickname.clone()); + update.agent_role = Some(meta_line.meta.agent_role.clone()); + update.agent_path = Some(meta_line.meta.agent_path.clone()); + if let Some(model_provider) = meta_line.meta.model_provider.clone() + && !model_provider.is_empty() + { + update.model_provider = Some(model_provider); + } + if !meta_line.meta.cli_version.is_empty() { + update.cli_version = Some(meta_line.meta.cli_version.clone()); + } + if !meta_line.meta.cwd.as_os_str().is_empty() { + self.cwd_seen = true; + update.cwd = Some(meta_line.meta.cwd.clone()); + } + if let Some(git_info) = meta_line.git.clone() { + update.git_info = Some(git_info_patch_from_observation(git_info)); + } + if let Some(memory_mode) = meta_line.meta.memory_mode.as_deref() + && let Some(memory_mode) = parse_memory_mode(memory_mode) + { + update.memory_mode = Some(memory_mode); + } + if let Some(dynamic_tools) = meta_line.meta.dynamic_tools.clone() { + update.dynamic_tools = Some(dynamic_tools); + } + } + RolloutItem::TurnContext(turn_ctx) => { + if !self.cwd_seen && !turn_ctx.cwd.as_os_str().is_empty() { + self.cwd_seen = true; + update.cwd = Some(turn_ctx.cwd.clone()); + } + update.model = Some(turn_ctx.model.clone()); + update.reasoning_effort = turn_ctx.effort; + update.approval_mode = Some(turn_ctx.approval_policy); + update.sandbox_policy = Some(turn_ctx.sandbox_policy.clone()); + } + RolloutItem::EventMsg(EventMsg::UserMessage(user)) => { + if let Some(preview) = user_message_preview(user) { + if !self.first_user_message_seen { + self.first_user_message_seen = true; + update.first_user_message = Some(preview.clone()); + } + if !self.preview_seen { + self.preview_seen = true; + update.preview = Some(preview); + } + } + if !self.title_seen { + let title = strip_user_message_prefix(user.message.as_str()); + if !title.is_empty() { + self.title_seen = true; + update.title = Some(title.to_string()); + } + } + } + RolloutItem::EventMsg(EventMsg::TokenCount(token_count)) => { + if let Some(info) = token_count.info.as_ref() { + update.token_usage = Some(info.total_token_usage.clone()); + } + } + RolloutItem::EventMsg(EventMsg::ThreadGoalUpdated(event)) => { + if !self.preview_seen { + let objective = event.goal.objective.trim(); + if !objective.is_empty() { + self.preview_seen = true; + update.preview = Some(objective.to_string()); + } + } + } + RolloutItem::SessionMeta(_) + | RolloutItem::EventMsg(_) + | RolloutItem::ResponseItem(_) + | RolloutItem::Compacted(_) => {} + } + } + Some(update) + } + + fn merge_pending_update(&mut self, update: Option) { + let Some(update) = update else { + return; + }; + match self.pending_update.as_mut() { + Some(pending_update) => pending_update.merge(update), + None => self.pending_update = Some(update), + } + self.pending_update_generation = self.pending_update_generation.wrapping_add(1); + } +} + +fn parse_memory_mode(value: &str) -> Option { + match value { + "enabled" => Some(ThreadMemoryMode::Enabled), + "disabled" => Some(ThreadMemoryMode::Disabled), + _ => None, + } +} + +fn parse_session_timestamp(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value) + .map(|timestamp| timestamp.with_timezone(&Utc)) + .or_else(|_| { + NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H-%M-%S") + .map(|timestamp| DateTime::from_naive_utc_and_offset(timestamp, Utc)) + }) + .ok() +} + +fn strip_user_message_prefix(text: &str) -> &str { + match text.find(USER_MESSAGE_BEGIN) { + Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(), + None => text.trim(), + } +} + +fn user_message_preview(user: &UserMessageEvent) -> Option { + let message = strip_user_message_prefix(user.message.as_str()); + if !message.is_empty() { + return Some(message.to_string()); + } + if user + .images + .as_ref() + .is_some_and(|images| !images.is_empty()) + || !user.local_images.is_empty() + { + return Some(IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER.to_string()); + } + None +} + +fn thread_updated_at_touch() -> ThreadMetadataPatch { + ThreadMetadataPatch { + updated_at: Some(Utc::now()), + ..Default::default() + } +} + +fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { + update.rollout_path.is_some() + || update.preview.is_some() + || update.title.is_some() + || update.model_provider.is_some() + || update.model.is_some() + || update.reasoning_effort.is_some() + || update.created_at.is_some() + || update.source.is_some() + || update.thread_source.is_some() + || update.agent_nickname.is_some() + || update.agent_role.is_some() + || update.agent_path.is_some() + || update.cwd.is_some() + || update.cli_version.is_some() + || update.approval_mode.is_some() + || update.sandbox_policy.is_some() + || update.token_usage.is_some() + || update.first_user_message.is_some() + || update.git_info.is_some() + || update.memory_mode.is_some() + || update.dynamic_tools.is_some() +} + +fn git_info_patch_from_observation(git_info: GitInfo) -> GitInfoPatch { + GitInfoPatch { + sha: git_info.commit_hash.map(|sha| Some(sha.0)), + branch: git_info.branch.map(Some), + origin_url: git_info.repository_url.map(Some), + } +} + +#[cfg(test)] +mod tests { + use codex_protocol::protocol::CompactedItem; + use codex_protocol::protocol::SessionMeta; + use codex_protocol::protocol::SessionMetaLine; + use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::ThreadGoal; + use codex_protocol::protocol::ThreadGoalStatus; + use codex_protocol::protocol::ThreadGoalUpdatedEvent; + use codex_protocol::protocol::UserMessageEvent; + use pretty_assertions::assert_eq; + + use super::*; + use crate::ThreadEventPersistenceMode; + use crate::ThreadPersistenceMetadata; + + #[test] + fn resume_history_keeps_derived_metadata_pending_until_applied() { + let thread_id = ThreadId::new(); + let mut sync = ThreadMetadataSync::for_resume(&resume_params( + thread_id, + vec![ + RolloutItem::SessionMeta(session_meta(thread_id)), + RolloutItem::EventMsg(EventMsg::UserMessage(user_message("hello metadata"))), + ], + )); + + let update = sync.take_pending_update().expect("pending metadata update"); + assert_eq!( + update + .patch + .created_at + .expect("created_at should come from session metadata") + .to_rfc3339(), + "2025-01-03T12:00:00+00:00" + ); + assert_eq!(update.patch.preview.as_deref(), Some("hello metadata")); + assert_eq!(update.patch.title.as_deref(), Some("hello metadata")); + assert_eq!( + update.patch.first_user_message.as_deref(), + Some("hello metadata") + ); + assert_eq!(update.patch.updated_at, None); + assert!( + sync.take_pending_update().is_some(), + "taking the pending update should not drop retry state" + ); + + sync.mark_pending_update_applied(&update); + assert!(sync.take_pending_update().is_none()); + } + + #[test] + fn goal_update_sets_preview_without_overriding_existing_preview() { + let thread_id = ThreadId::new(); + let sync = ThreadMetadataSync::for_resume(&resume_params( + thread_id, + vec![ + RolloutItem::EventMsg(EventMsg::ThreadGoalUpdated(goal_update( + thread_id, + "ship the refactor", + ))), + RolloutItem::EventMsg(EventMsg::UserMessage(user_message("first user text"))), + ], + )); + + let update = sync.take_pending_update().expect("pending metadata update"); + assert_eq!(update.patch.preview.as_deref(), Some("ship the refactor")); + assert_eq!( + update.patch.first_user_message.as_deref(), + Some("first user text") + ); + assert_eq!(update.patch.title.as_deref(), Some("first user text")); + } + + #[test] + fn later_user_messages_do_not_emit_existing_preview_fields() { + let thread_id = ThreadId::new(); + let mut sync = ThreadMetadataSync::for_resume(&resume_params( + thread_id, + vec![RolloutItem::EventMsg(EventMsg::UserMessage(user_message( + "first user text", + )))], + )); + let pending = sync.take_pending_update().expect("pending resume metadata"); + sync.mark_pending_update_applied(&pending); + + let update = sync + .observe_appended_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(user_message( + "later user text", + )))]) + .expect("updated_at touch"); + + assert_eq!(update.patch.preview, None); + assert_eq!(update.patch.title, None); + assert_eq!(update.patch.first_user_message, None); + assert!(update.patch.updated_at.is_some()); + } + + #[test] + fn metadata_irrelevant_items_coalesce_updated_at_touches() { + let thread_id = ThreadId::new(); + let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new())); + let item = RolloutItem::Compacted(CompactedItem { + message: "compacted".to_string(), + replacement_history: None, + }); + + let first = sync + .observe_appended_items(std::slice::from_ref(&item)) + .expect("first touch should apply immediately"); + assert!(first.patch.updated_at.is_some()); + sync.mark_pending_update_applied(&first); + + assert!( + sync.observe_appended_items(std::slice::from_ref(&item)) + .is_none(), + "second touch inside the coalescing window should wait for a barrier" + ); + assert!( + sync.take_pending_update().is_some(), + "coalesced touches still flush at the next barrier" + ); + } + + #[test] + fn resume_history_waits_for_append_before_flushing_metadata() { + let thread_id = ThreadId::new(); + let mut sync = ThreadMetadataSync::for_resume(&resume_params( + thread_id, + vec![ + RolloutItem::SessionMeta(session_meta(thread_id)), + RolloutItem::EventMsg(EventMsg::UserMessage(user_message("hello metadata"))), + ], + )); + + assert!( + sync.take_pending_update_for_existing_history().is_none(), + "resume-only metadata should not flush without a new append" + ); + assert!( + sync.observe_appended_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( + user_message("new append"), + ))]) + .is_some(), + "the first append should flush resume metadata together with append metadata" + ); + } + + fn resume_params(thread_id: ThreadId, history: Vec) -> ResumeThreadParams { + ResumeThreadParams { + thread_id, + rollout_path: None, + history: Some(history), + include_archived: false, + metadata: ThreadPersistenceMetadata { + cwd: None, + model_provider: "test-provider".to_string(), + memory_mode: ThreadMemoryMode::Enabled, + }, + event_persistence_mode: ThreadEventPersistenceMode::Limited, + } + } + + fn user_message(message: &str) -> UserMessageEvent { + UserMessageEvent { + message: message.to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + } + } + + fn session_meta(thread_id: ThreadId) -> SessionMetaLine { + SessionMetaLine { + meta: SessionMeta { + id: thread_id, + timestamp: "2025-01-03T12:00:00Z".to_string(), + source: SessionSource::Exec, + ..Default::default() + }, + git: None, + } + } + + fn goal_update(thread_id: ThreadId, objective: &str) -> ThreadGoalUpdatedEvent { + ThreadGoalUpdatedEvent { + thread_id, + turn_id: None, + goal: ThreadGoal { + thread_id, + objective: objective.to_string(), + status: ThreadGoalStatus::Active, + token_budget: None, + tokens_used: 0, + time_used_seconds: 0, + created_at: 0, + updated_at: 0, + }, + } + } +} diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 1fed7bc829..508f6321a3 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -15,7 +15,32 @@ use codex_protocol::protocol::ThreadMemoryMode as MemoryMode; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; +use serde::Serializer; + +mod optional_option { + use super::*; + + pub fn serialize(value: &Option>, serializer: S) -> Result + where + T: Serialize, + S: Serializer, + { + match value { + Some(value) => value.serialize(serializer), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, T, D>(deserializer: D) -> Result>, D::Error> + where + T: Deserialize<'de>, + D: Deserializer<'de>, + { + Option::::deserialize(deserializer).map(Some) + } +} /// Controls how many event variants should be persisted for future replay. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -348,32 +373,241 @@ pub struct StoredThread { } /// Optional field patch where omission leaves a value unchanged and `Some(None)` clears it. -pub type OptionalStringPatch = Option>; +pub type ClearableField = Option>; /// Patch for thread Git metadata. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct GitInfoPatch { /// Replacement commit SHA, clear request, or no-op. - pub sha: OptionalStringPatch, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_option" + )] + pub sha: ClearableField, /// Replacement branch name, clear request, or no-op. - pub branch: OptionalStringPatch, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_option" + )] + pub branch: ClearableField, /// Replacement origin URL, clear request, or no-op. - pub origin_url: OptionalStringPatch, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_option" + )] + pub origin_url: ClearableField, } -/// Patch for mutable thread metadata. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +impl GitInfoPatch { + /// Merges another patch into this one using field-presence semantics. + /// + /// Omitted fields in `next` leave the current patch unchanged. Present fields replace the + /// current value, including clear requests like `Some(None)`. + pub fn merge(&mut self, next: Self) { + if next.sha.is_some() { + self.sha = next.sha; + } + if next.branch.is_some() { + self.branch = next.branch; + } + if next.origin_url.is_some() { + self.origin_url = next.origin_url; + } + } +} + +/// Patch for thread metadata. +/// +/// Every field is literal: `None` leaves that field unchanged, while `Some` +/// applies the supplied value. Fields whose value may itself be cleared use an +/// inner `Option`, where `Some(None)` clears the field. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct ThreadMetadataPatch { /// Replacement user-facing thread name. - pub name: Option, - /// Replacement thread memory behavior. - pub memory_mode: Option, - /// Optional Git metadata patch. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_option" + )] + pub name: ClearableField, + /// Known local rollout path for stores that expose one. + pub rollout_path: Option, + /// Best available preview text for discovery/listing. + pub preview: Option, + /// Best-effort title derived from history. + pub title: Option, + /// Model provider associated with the thread. + pub model_provider: Option, + /// Latest observed model. + pub model: Option, + /// Latest observed reasoning effort. + pub reasoning_effort: Option, + /// Creation timestamp when known. + pub created_at: Option>, + /// Last update timestamp for this metadata observation. + pub updated_at: Option>, + /// Session source. + pub source: Option, + /// Optional analytics source classification. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_option" + )] + pub thread_source: ClearableField, + /// Optional agent nickname. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_option" + )] + pub agent_nickname: ClearableField, + /// Optional agent role. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_option" + )] + pub agent_role: ClearableField, + /// Optional canonical agent path. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_option" + )] + pub agent_path: ClearableField, + /// Working directory. + pub cwd: Option, + /// CLI version that created the thread. + pub cli_version: Option, + /// Approval mode. + pub approval_mode: Option, + /// Sandbox policy. + pub sandbox_policy: Option, + /// Last observed token usage. + pub token_usage: Option, + /// First user message observed for this thread. + pub first_user_message: Option, + /// Git metadata patch. pub git_info: Option, + /// Thread memory behavior. + pub memory_mode: Option, + /// Dynamic tools available to this thread. + pub dynamic_tools: Option>, +} + +impl ThreadMetadataPatch { + /// Merges another patch into this one using field-presence semantics. + /// + /// Omitted fields in `next` leave the current patch unchanged. Present fields replace the + /// current value, including clear requests like `Some(None)`. Nested patches use the same + /// semantics. + pub fn merge(&mut self, next: Self) { + if next.name.is_some() { + self.name = next.name; + } + if next.rollout_path.is_some() { + self.rollout_path = next.rollout_path; + } + if next.preview.is_some() { + self.preview = next.preview; + } + if next.title.is_some() { + self.title = next.title; + } + if next.model_provider.is_some() { + self.model_provider = next.model_provider; + } + if next.model.is_some() { + self.model = next.model; + } + if next.reasoning_effort.is_some() { + self.reasoning_effort = next.reasoning_effort; + } + if next.created_at.is_some() { + self.created_at = next.created_at; + } + if next.updated_at.is_some() { + self.updated_at = next.updated_at; + } + if next.source.is_some() { + self.source = next.source; + } + if next.thread_source.is_some() { + self.thread_source = next.thread_source; + } + if next.agent_nickname.is_some() { + self.agent_nickname = next.agent_nickname; + } + if next.agent_role.is_some() { + self.agent_role = next.agent_role; + } + if next.agent_path.is_some() { + self.agent_path = next.agent_path; + } + if next.cwd.is_some() { + self.cwd = next.cwd; + } + if next.cli_version.is_some() { + self.cli_version = next.cli_version; + } + if next.approval_mode.is_some() { + self.approval_mode = next.approval_mode; + } + if next.sandbox_policy.is_some() { + self.sandbox_policy = next.sandbox_policy; + } + if next.token_usage.is_some() { + self.token_usage = next.token_usage; + } + if next.first_user_message.is_some() { + self.first_user_message = next.first_user_message; + } + if let Some(git_info) = next.git_info { + self.git_info + .get_or_insert_with(GitInfoPatch::default) + .merge(git_info); + } + if next.memory_mode.is_some() { + self.memory_mode = next.memory_mode; + } + if next.dynamic_tools.is_some() { + self.dynamic_tools = next.dynamic_tools; + } + } + + pub fn is_empty(&self) -> bool { + self.name.is_none() + && self.rollout_path.is_none() + && self.preview.is_none() + && self.title.is_none() + && self.model_provider.is_none() + && self.model.is_none() + && self.reasoning_effort.is_none() + && self.created_at.is_none() + && self.updated_at.is_none() + && self.source.is_none() + && self.thread_source.is_none() + && self.agent_nickname.is_none() + && self.agent_role.is_none() + && self.agent_path.is_none() + && self.cwd.is_none() + && self.cli_version.is_none() + && self.approval_mode.is_none() + && self.sandbox_policy.is_none() + && self.token_usage.is_none() + && self.first_user_message.is_none() + && self.git_info.is_none() + && self.memory_mode.is_none() + && self.dynamic_tools.is_none() + } } /// Parameters for patching mutable thread metadata. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct UpdateThreadMetadataParams { /// Thread id to update. pub thread_id: ThreadId, @@ -389,3 +623,116 @@ pub struct ArchiveThreadParams { /// Thread id to archive or unarchive. pub thread_id: ThreadId, } + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde_json::json; + + use super::*; + + #[test] + fn thread_metadata_patch_round_trips_optional_clears() { + let patch = ThreadMetadataPatch { + name: Some(None), + thread_source: Some(None), + agent_nickname: Some(None), + agent_role: Some(None), + agent_path: Some(None), + ..Default::default() + }; + + let value = serde_json::to_value(&patch).expect("serialize patch"); + assert_eq!(value["name"], json!(null)); + assert_eq!(value["thread_source"], json!(null)); + assert_eq!(value["agent_nickname"], json!(null)); + assert_eq!(value["agent_role"], json!(null)); + assert_eq!(value["agent_path"], json!(null)); + + let decoded: ThreadMetadataPatch = + serde_json::from_value(value).expect("deserialize patch"); + assert_eq!(decoded.name, Some(None)); + assert_eq!(decoded.thread_source, Some(None)); + assert_eq!(decoded.agent_nickname, Some(None)); + assert_eq!(decoded.agent_role, Some(None)); + assert_eq!(decoded.agent_path, Some(None)); + } + + #[test] + fn git_info_patch_round_trips_optional_clears() { + let patch = ThreadMetadataPatch { + git_info: Some(GitInfoPatch { + sha: None, + branch: Some(Some("main".to_string())), + origin_url: Some(None), + }), + ..Default::default() + }; + + let value = serde_json::to_value(&patch).expect("serialize patch"); + assert_eq!( + value["git_info"], + json!({ + "branch": "main", + "origin_url": null, + }) + ); + + let decoded: ThreadMetadataPatch = + serde_json::from_value(value).expect("deserialize patch"); + assert_eq!( + decoded.git_info, + Some(GitInfoPatch { + sha: None, + branch: Some(Some("main".to_string())), + origin_url: Some(None), + }) + ); + } + + #[test] + fn thread_metadata_patch_accepts_missing_fields() { + let decoded: ThreadMetadataPatch = + serde_json::from_value(json!({})).expect("deserialize legacy patch"); + + assert!(decoded.is_empty()); + } + + #[test] + fn thread_metadata_patch_merge_uses_presence_semantics() { + let mut current = ThreadMetadataPatch { + name: Some(Some("old name".to_string())), + preview: Some("old preview".to_string()), + git_info: Some(GitInfoPatch { + sha: Some(Some("abc123".to_string())), + branch: Some(Some("main".to_string())), + origin_url: None, + }), + ..Default::default() + }; + + current.merge(ThreadMetadataPatch { + name: Some(None), + preview: None, + title: Some("new title".to_string()), + git_info: Some(GitInfoPatch { + sha: None, + branch: Some(Some("feature".to_string())), + origin_url: Some(None), + }), + ..Default::default() + }); + + assert_eq!(current.name, Some(None)); + assert_eq!(current.preview.as_deref(), Some("old preview")); + assert_eq!(current.title.as_deref(), Some("new title")); + assert_eq!( + current.git_info, + Some(GitInfoPatch { + sha: Some(Some("abc123".to_string())), + branch: Some(Some("feature".to_string())), + origin_url: Some(None), + }) + ); + } +} diff --git a/codex-rs/tool-api/BUILD.bazel b/codex-rs/tool-api/BUILD.bazel deleted file mode 100644 index 1dc9eb3bf1..0000000000 --- a/codex-rs/tool-api/BUILD.bazel +++ /dev/null @@ -1,6 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "tool-api", - crate_name = "codex_tool_api", -) diff --git a/codex-rs/tool-api/Cargo.toml b/codex-rs/tool-api/Cargo.toml deleted file mode 100644 index 13d1a22071..0000000000 --- a/codex-rs/tool-api/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -edition.workspace = true -license.workspace = true -name = "codex-tool-api" -version.workspace = true - -[lib] -name = "codex_tool_api" -path = "src/lib.rs" -doctest = false - -[lints] -workspace = true - -[dependencies] -serde_json = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -pretty_assertions = { workspace = true } diff --git a/codex-rs/tool-api/README.md b/codex-rs/tool-api/README.md deleted file mode 100644 index 418c1d177c..0000000000 --- a/codex-rs/tool-api/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# codex-tool-api - -`codex-tool-api` is the minimal extension-facing contract for contributed -function tools that can be injected into Codex without making `codex-core` -depend on the tool owner's crate. - -Crates that define contributed tools should depend on this crate. It owns: - -- the executable bundle contract: `ToolBundle`, `ToolExecutor`, `ToolCall`, - and `ToolError` -- the one model-visible spec an extension may contribute directly: - `FunctionToolSpec` - -The contract is intentionally narrow: contributed tools receive a call id plus -raw JSON arguments and return a JSON value. If a feature needs richer host -integration, its extension is expected to do that wiring before exposing the -tool rather than widening this crate around the hardest native tools. - -The intended dependency direction is: - -```text -tool-owning extension crate --> codex-tool-api <-- codex-core -``` - -`codex-tools` has a different job. It remains the host-side owner of Responses -API tool models, schema parsing, namespaces, discovery, MCP/dynamic conversion, -code-mode shaping, and other aggregate host concerns. A crate that only wants -to contribute one ordinary function tool through an extension should not need -to depend on `codex-tools`. diff --git a/codex-rs/tool-api/src/bundle.rs b/codex-rs/tool-api/src/bundle.rs deleted file mode 100644 index 18c84c4c07..0000000000 --- a/codex-rs/tool-api/src/bundle.rs +++ /dev/null @@ -1,87 +0,0 @@ -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -use serde_json::Value; - -use crate::FunctionToolSpec; -use crate::ToolCall; -use crate::ToolError; - -/// Future returned by one contributed function-tool invocation. -pub type ToolFuture<'a> = Pin> + Send + 'a>>; - -/// Model-visible definition plus executable implementation for one contributed -/// function tool. -#[derive(Clone)] -pub struct ToolBundle { - spec: FunctionToolSpec, - executor: Arc, -} - -impl ToolBundle { - /// Creates one contributed function-tool bundle. - pub fn new(spec: FunctionToolSpec, executor: Arc) -> Self { - Self { spec, executor } - } - - /// Returns the contributed function-tool spec. - pub fn spec(&self) -> &FunctionToolSpec { - &self.spec - } - - /// Returns the contributed function-tool name. - pub fn tool_name(&self) -> &str { - self.spec.name.as_str() - } - - /// Returns the executable implementation. - pub fn executor(&self) -> Arc { - Arc::clone(&self.executor) - } -} - -/// Executable behavior for one contributed function tool. -/// -/// Implementations receive the model-supplied call id and JSON arguments and -/// return the JSON value that should be exposed to the model. -pub trait ToolExecutor: Send + Sync { - fn execute<'a>(&'a self, call: ToolCall) -> ToolFuture<'a>; -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use pretty_assertions::assert_eq; - use serde_json::json; - - use super::ToolBundle; - use super::ToolExecutor; - use super::ToolFuture; - use crate::FunctionToolSpec; - use crate::ToolCall; - - struct StubExecutor; - - impl ToolExecutor for StubExecutor { - fn execute<'a>(&'a self, _call: ToolCall) -> ToolFuture<'a> { - Box::pin(async { Ok(json!({ "ok": true })) }) - } - } - - #[test] - fn bundle_derives_name_from_function_spec() { - let bundle = ToolBundle::new( - FunctionToolSpec { - name: "echo".to_string(), - description: "Echo arguments.".to_string(), - strict: false, - parameters: json!({ "type": "object" }), - }, - Arc::new(StubExecutor), - ); - - assert_eq!(bundle.tool_name(), "echo"); - } -} diff --git a/codex-rs/tool-api/src/call.rs b/codex-rs/tool-api/src/call.rs deleted file mode 100644 index bcfab2092c..0000000000 --- a/codex-rs/tool-api/src/call.rs +++ /dev/null @@ -1,5 +0,0 @@ -/// One contributed function-tool call. -pub struct ToolCall { - pub call_id: String, - pub arguments: String, -} diff --git a/codex-rs/tool-api/src/error.rs b/codex-rs/tool-api/src/error.rs deleted file mode 100644 index c4d30e63d2..0000000000 --- a/codex-rs/tool-api/src/error.rs +++ /dev/null @@ -1,22 +0,0 @@ -use thiserror::Error; - -/// Error returned by a contributed executable tool. -#[derive(Clone, Debug, Error, PartialEq, Eq)] -pub enum ToolError { - #[error("{0}")] - RespondToModel(String), - #[error("fatal tool error: {0}")] - Fatal(String), -} - -impl ToolError { - /// Creates a model-visible tool error. - pub fn respond_to_model(message: impl Into) -> Self { - Self::RespondToModel(message.into()) - } - - /// Creates a host-fatal tool error. - pub fn fatal(message: impl Into) -> Self { - Self::Fatal(message.into()) - } -} diff --git a/codex-rs/tool-api/src/lib.rs b/codex-rs/tool-api/src/lib.rs deleted file mode 100644 index 11788f5649..0000000000 --- a/codex-rs/tool-api/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Minimal function-tool contracts shared between hosts and extension-owned -//! tool crates. - -mod bundle; -mod call; -mod error; -mod spec; - -pub use bundle::ToolBundle; -pub use bundle::ToolExecutor; -pub use bundle::ToolFuture; -pub use call::ToolCall; -pub use error::ToolError; -pub use spec::FunctionToolSpec; diff --git a/codex-rs/tool-api/src/spec.rs b/codex-rs/tool-api/src/spec.rs deleted file mode 100644 index 1b25837bb3..0000000000 --- a/codex-rs/tool-api/src/spec.rs +++ /dev/null @@ -1,10 +0,0 @@ -use serde_json::Value; - -/// Model-visible definition for one contributed function tool. -#[derive(Debug, Clone, PartialEq)] -pub struct FunctionToolSpec { - pub name: String, - pub description: String, - pub strict: bool, - pub parameters: Value, -} diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index 0029352d4b..b11ee2fe7c 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -14,6 +14,7 @@ codex-features = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } +codex-utils-string = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ "base64", "macros", @@ -22,6 +23,7 @@ rmcp = { workspace = true, default-features = false, features = [ ] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +thiserror = { workspace = true } tracing = { workspace = true } [dev-dependencies] diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index 79035dde3c..68f4080957 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -1,11 +1,7 @@ # codex-tools -`codex-tools` is the host-side support crate for building, adapting, and -planning tool sets outside `codex-core`. - -It is deliberately not the API that external tool owners should depend on. -Crates that contribute ordinary function tools through extensions should use -`codex-tool-api`, which owns only that small executable-tool seam. +`codex-tools` is the shared support crate for building, adapting, planning, and +executing model-visible tool sets outside `codex-core`. Today this crate owns the host-facing tool models and helpers that no longer need to live in `core/src/tools/spec.rs` or `core/src/client_common.rs`: @@ -17,6 +13,8 @@ need to live in `core/src/tools/spec.rs` or `core/src/client_common.rs`: `ToolsConfig`, discoverable-tool models, and request-plugin-install helpers - host adapters such as schema sanitization, MCP/dynamic conversion, code-mode augmentation, and image-detail normalization +- shared executable-tool contracts such as `ToolExecutor`, `ToolCall`, and + `ToolOutput` That extraction is the first step in a longer migration. The goal is not to move all of `core/src/tools` into this crate in one shot. Instead, the plan is @@ -37,7 +35,6 @@ multiple consumers, for example: The corresponding non-goals are just as important: -- do not become the extension-facing authoring API for executable tools - do not move `codex-core` orchestration here prematurely - do not pull `Session` / `TurnContext` / approval flow / runtime execution logic into this crate unless those dependencies have first been split into @@ -48,7 +45,7 @@ The corresponding non-goals are just as important: The expected migration shape is: -1. Keep ordinary contributed function-tool authoring in `codex-tool-api`. +1. Keep extension-owned executable-tool authoring in `codex-extension-api`. 2. Move host-side planning/adaptation helpers here when they no longer need to stay coupled to `codex-core`. 3. Leave compatibility-sensitive adapters in `codex-core` while downstream diff --git a/codex-rs/tools/src/code_mode.rs b/codex-rs/tools/src/code_mode.rs index a0c2173cac..052edbac59 100644 --- a/codex-rs/tools/src/code_mode.rs +++ b/codex-rs/tools/src/code_mode.rs @@ -136,8 +136,7 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec Vec::new(), } diff --git a/codex-rs/tools/src/function_call_error.rs b/codex-rs/tools/src/function_call_error.rs new file mode 100644 index 0000000000..1ab8335b25 --- /dev/null +++ b/codex-rs/tools/src/function_call_error.rs @@ -0,0 +1,10 @@ +use thiserror::Error; + +/// Error returned while executing a model-visible tool invocation. +#[derive(Debug, Error, PartialEq)] +pub enum FunctionCallError { + #[error("{0}")] + RespondToModel(String), + #[error("Fatal error: {0}")] + Fatal(String), +} diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 8c63e6e631..2a87ba35d7 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -3,14 +3,19 @@ mod code_mode; mod dynamic_tool; +mod function_call_error; mod image_detail; mod json_schema; mod mcp_tool; mod request_plugin_install; mod responses_api; +mod tool_call; mod tool_config; mod tool_definition; mod tool_discovery; +mod tool_executor; +mod tool_output; +mod tool_payload; mod tool_spec; pub use code_mode::augment_tool_spec_for_code_mode; @@ -20,6 +25,7 @@ pub use code_mode::collect_code_mode_tool_definitions; pub use code_mode::tool_spec_to_code_mode_tool_definition; pub use codex_protocol::ToolName; pub use dynamic_tool::parse_dynamic_tool; +pub use function_call_error::FunctionCallError; pub use image_detail::can_request_original_image_detail; pub use image_detail::normalize_output_image_detail; pub use image_detail::sanitize_original_image_detail; @@ -47,11 +53,11 @@ pub use responses_api::ResponsesApiNamespaceTool; pub use responses_api::ResponsesApiTool; pub use responses_api::coalesce_loadable_tool_specs; pub use responses_api::default_namespace_description; -pub use responses_api::dynamic_tool_to_loadable_tool_spec; pub use responses_api::dynamic_tool_to_responses_api_tool; 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_call::ToolCall; pub use tool_config::ShellCommandBackendConfig; pub use tool_config::ToolEnvironmentMode; pub use tool_config::ToolUserShellType; @@ -69,13 +75,14 @@ pub use tool_discovery::REQUEST_PLUGIN_INSTALL_TOOL_NAME; pub use tool_discovery::RequestPluginInstallEntry; pub use tool_discovery::TOOL_SEARCH_DEFAULT_LIMIT; pub use tool_discovery::TOOL_SEARCH_TOOL_NAME; -pub use tool_discovery::ToolSearchResultSource; -pub use tool_discovery::ToolSearchSource; pub use tool_discovery::ToolSearchSourceInfo; pub use tool_discovery::collect_request_plugin_install_entries; -pub use tool_discovery::collect_tool_search_source_infos; pub use tool_discovery::filter_request_plugin_install_discoverable_tools_for_client; -pub use tool_discovery::tool_search_result_source_to_loadable_tool_spec; +pub use tool_executor::ToolExecutor; +pub use tool_executor::ToolExposure; +pub use tool_output::JsonToolOutput; +pub use tool_output::ToolOutput; +pub use tool_payload::ToolPayload; pub use tool_spec::ResponsesApiWebSearchFilters; pub use tool_spec::ResponsesApiWebSearchUserLocation; pub use tool_spec::ToolSpec; diff --git a/codex-rs/tools/src/responses_api.rs b/codex-rs/tools/src/responses_api.rs index a5b26abae4..013c491cf6 100644 --- a/codex-rs/tools/src/responses_api.rs +++ b/codex-rs/tools/src/responses_api.rs @@ -74,21 +74,6 @@ pub fn dynamic_tool_to_responses_api_tool( )?)) } -pub fn dynamic_tool_to_loadable_tool_spec( - tool: &DynamicToolSpec, -) -> Result { - let output_tool = dynamic_tool_to_responses_api_tool(tool)?; - Ok(match tool.namespace.as_ref() { - Some(namespace) => LoadableToolSpec::Namespace(ResponsesApiNamespace { - name: namespace.clone(), - // the user doesn't provide a description for dynamic tools, so we use the default - description: default_namespace_description(namespace), - tools: vec![ResponsesApiNamespaceTool::Function(output_tool)], - }), - None => LoadableToolSpec::Function(output_tool), - }) -} - pub fn coalesce_loadable_tool_specs( specs: impl IntoIterator, ) -> Vec { diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs new file mode 100644 index 0000000000..f12a338cfa --- /dev/null +++ b/codex-rs/tools/src/tool_call.rs @@ -0,0 +1,23 @@ +use crate::FunctionCallError; +use crate::ToolName; +use crate::ToolPayload; + +// TODO: this is temporary and will disappear in the next PR (as we make codex-extension-api generic on Invocation. +#[derive(Clone, Debug)] +pub struct ToolCall { + pub call_id: String, + pub tool_name: ToolName, + pub payload: ToolPayload, +} + +impl ToolCall { + pub fn function_arguments(&self) -> Result<&str, FunctionCallError> { + match &self.payload { + ToolPayload::Function { arguments } => Ok(arguments), + _ => Err(FunctionCallError::Fatal(format!( + "tool {} invoked with incompatible payload", + self.tool_name + ))), + } + } +} diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 4639b404b1..390c4ee386 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -117,6 +117,7 @@ pub struct ToolsConfig { pub collab_tools: bool, pub goal_tools: bool, pub multi_agent_v2: bool, + pub multi_agent_v2_non_code_mode_only: bool, pub hide_spawn_agent_metadata: bool, pub spawn_agent_usage_hint: bool, pub spawn_agent_usage_hint_text: Option, @@ -203,6 +204,9 @@ impl ToolsConfig { ConfigShellToolType::UnifiedExec if !unified_exec_enabled => { ConfigShellToolType::ShellCommand } + ConfigShellToolType::Default | ConfigShellToolType::Local => { + ConfigShellToolType::ShellCommand + } other => other, }; let shell_type = if !features.enabled(Feature::ShellTool) { @@ -254,6 +258,7 @@ impl ToolsConfig { collab_tools: include_collab_tools, goal_tools: include_goal_tools, multi_agent_v2: include_multi_agent_v2, + multi_agent_v2_non_code_mode_only: false, hide_spawn_agent_metadata: false, spawn_agent_usage_hint: true, spawn_agent_usage_hint_text: None, @@ -311,6 +316,15 @@ impl ToolsConfig { self } + pub fn with_multi_agent_v2_non_code_mode_only( + mut self, + multi_agent_v2_non_code_mode_only: bool, + ) -> Self { + self.multi_agent_v2_non_code_mode_only = + self.multi_agent_v2 && multi_agent_v2_non_code_mode_only; + self + } + pub fn with_goal_tools_allowed(mut self, allowed: bool) -> Self { self.goal_tools = self.goal_tools && allowed; self diff --git a/codex-rs/tools/src/tool_discovery.rs b/codex-rs/tools/src/tool_discovery.rs index d95b9f7e32..a5beb768a2 100644 --- a/codex-rs/tools/src/tool_discovery.rs +++ b/codex-rs/tools/src/tool_discovery.rs @@ -1,9 +1,3 @@ -use crate::LoadableToolSpec; -use crate::ResponsesApiNamespace; -use crate::ResponsesApiNamespaceTool; -use crate::ToolName; -use crate::default_namespace_description; -use crate::mcp_tool_to_deferred_responses_api_tool; use codex_app_server_protocol::AppInfo; use serde::Deserialize; use serde::Serialize; @@ -19,23 +13,6 @@ pub struct ToolSearchSourceInfo { pub description: Option, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ToolSearchSource<'a> { - pub server_name: &'a str, - pub connector_name: Option<&'a str>, - pub description: Option<&'a str>, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct ToolSearchResultSource<'a> { - pub server_name: &'a str, - pub tool_namespace: &'a str, - pub tool_name: &'a str, - pub tool: &'a rmcp::model::Tool, - pub connector_name: Option<&'a str>, - pub description: Option<&'a str>, -} - #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum DiscoverableToolType { @@ -133,78 +110,6 @@ pub struct RequestPluginInstallEntry { pub app_connector_ids: Vec, } -pub fn tool_search_result_source_to_loadable_tool_spec( - source: ToolSearchResultSource<'_>, -) -> Result { - Ok(LoadableToolSpec::Namespace(ResponsesApiNamespace { - name: source.tool_namespace.to_string(), - description: tool_search_result_source_namespace_description(source), - tools: vec![tool_search_result_source_to_namespace_tool(source)?], - })) -} - -fn tool_search_result_source_namespace_description(source: ToolSearchResultSource<'_>) -> String { - source - .description - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string) - .or_else(|| { - source - .connector_name - .map(str::trim) - .filter(|connector_name| !connector_name.is_empty()) - .map(|connector_name| format!("Tools for working with {connector_name}.")) - }) - .unwrap_or_else(|| default_namespace_description(source.tool_namespace)) -} - -fn tool_search_result_source_to_namespace_tool( - source: ToolSearchResultSource<'_>, -) -> Result { - let tool_name = ToolName::namespaced(source.tool_namespace, source.tool_name); - mcp_tool_to_deferred_responses_api_tool(&tool_name, source.tool) - .map(ResponsesApiNamespaceTool::Function) -} - -pub fn collect_tool_search_source_infos<'a>( - searchable_tools: impl IntoIterator>, -) -> Vec { - searchable_tools - .into_iter() - .filter_map(|tool| { - if let Some(name) = tool - .connector_name - .map(str::trim) - .filter(|connector_name| !connector_name.is_empty()) - { - return Some(ToolSearchSourceInfo { - name: name.to_string(), - description: tool - .description - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string), - }); - } - - let name = tool.server_name.trim(); - if name.is_empty() { - return None; - } - - Some(ToolSearchSourceInfo { - name: name.to_string(), - description: tool - .description - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string), - }) - }) - .collect() -} - pub fn collect_request_plugin_install_entries( discoverable_tools: &[DiscoverableTool], ) -> Vec { diff --git a/codex-rs/tools/src/tool_executor.rs b/codex-rs/tools/src/tool_executor.rs new file mode 100644 index 0000000000..4dc328523f --- /dev/null +++ b/codex-rs/tools/src/tool_executor.rs @@ -0,0 +1,61 @@ +use std::future::Future; + +use crate::FunctionCallError; +use crate::ToolName; +use crate::ToolOutput; +use crate::ToolSpec; + +/// Controls where a tool is exposed to the model. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ToolExposure { + /// Include this tool in the initial model-visible tool list. + /// + /// When code mode is enabled, this tool is also available as a nested + /// code-mode tool. + Direct, + + /// Register this tool for later discovery, but omit it from the initial + /// model-visible tool list. + Deferred, + + /// Include this tool in the initial model-visible tool list only. + /// + /// In code-mode-only sessions, this keeps the tool callable as a normal + /// model tool while excluding it from the nested code-mode tool surface. + DirectModelOnly, +} + +impl ToolExposure { + pub fn is_direct(self) -> bool { + matches!(self, Self::Direct | Self::DirectModelOnly) + } +} + +/// Shared runtime contract for model-visible tools. +/// +/// Implementations keep the model-visible spec tied to the executable runtime. +/// Host crates can layer routing, hooks, telemetry, or other orchestration on +/// top without reopening the spec/runtime split. +pub trait ToolExecutor: Send + Sync { + type Output: ToolOutput + 'static; + + /// The concrete tool name handled by this runtime instance. + fn tool_name(&self) -> ToolName; + + fn spec(&self) -> Option { + None + } + + fn exposure(&self) -> ToolExposure { + ToolExposure::Direct + } + + fn supports_parallel_tool_calls(&self) -> bool { + false + } + + fn handle( + &self, + invocation: Invocation, + ) -> impl Future> + Send; +} diff --git a/codex-rs/tools/src/tool_output.rs b/codex-rs/tools/src/tool_output.rs new file mode 100644 index 0000000000..2044295174 --- /dev/null +++ b/codex-rs/tools/src/tool_output.rs @@ -0,0 +1,239 @@ +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; +use codex_utils_string::take_bytes_at_char_boundary; +use serde_json::Value as JsonValue; + +use crate::ToolPayload; + +const TELEMETRY_PREVIEW_MAX_BYTES: usize = 2 * 1024; +const TELEMETRY_PREVIEW_MAX_LINES: usize = 64; +const TELEMETRY_PREVIEW_TRUNCATION_NOTICE: &str = "[... telemetry preview truncated ...]"; + +/// Model-facing output contract returned by executable tool runtimes. +pub trait ToolOutput: Send { + fn log_preview(&self) -> String; + + fn success_for_logging(&self) -> bool; + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem; + + /// Returns the stable value exposed to `PostToolUse` hooks for this tool output. + /// + /// Tool handlers decide whether a tool participates in `PostToolUse`, but + /// this method lets the output type own any conversion from model-facing + /// response content to hook-facing data. Returning `None` means the output + /// should not produce a post-use hook payload, not merely that the tool had + /// empty output. + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { + None + } + + fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue { + response_input_to_code_mode_result(self.to_response_item("", payload)) + } +} + +impl ToolOutput for Box +where + T: ToolOutput + ?Sized, +{ + fn log_preview(&self) -> String { + (**self).log_preview() + } + + fn success_for_logging(&self) -> bool { + (**self).success_for_logging() + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + (**self).to_response_item(call_id, payload) + } + + fn post_tool_use_response(&self, call_id: &str, payload: &ToolPayload) -> Option { + (**self).post_tool_use_response(call_id, payload) + } + + fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue { + (**self).code_mode_result(payload) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct JsonToolOutput { + value: JsonValue, + success: Option, +} + +impl JsonToolOutput { + pub fn new(value: JsonValue) -> Self { + Self { + value, + success: Some(true), + } + } + + pub fn with_success(value: JsonValue, success: Option) -> Self { + Self { value, success } + } +} + +impl ToolOutput for JsonToolOutput { + fn log_preview(&self) -> String { + telemetry_preview(&self.value.to_string()) + } + + fn success_for_logging(&self) -> bool { + self.success.unwrap_or(true) + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + let output = FunctionCallOutputPayload { + body: FunctionCallOutputBody::Text(self.value.to_string()), + success: self.success, + }; + + if matches!(payload, ToolPayload::Custom { .. }) { + return ResponseInputItem::CustomToolCallOutput { + call_id: call_id.to_string(), + name: None, + output, + }; + } + + ResponseInputItem::FunctionCallOutput { + call_id: call_id.to_string(), + output, + } + } + + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { + Some(self.value.clone()) + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + self.value.clone() + } +} + +impl ToolOutput for codex_protocol::mcp::CallToolResult { + fn log_preview(&self) -> String { + let output = self.as_function_call_output_payload(); + let preview = output.body.to_text().unwrap_or_else(|| output.to_string()); + telemetry_preview(&preview) + } + + fn success_for_logging(&self) -> bool { + self.success() + } + + fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { + ResponseInputItem::McpToolCallOutput { + call_id: call_id.to_string(), + output: self.clone(), + } + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + serde_json::to_value(self).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize mcp result: {err}")) + }) + } +} + +fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue { + match response { + ResponseInputItem::Message { content, .. } => content_items_to_code_mode_result( + &content + .into_iter() + .map(|item| match item { + codex_protocol::models::ContentItem::InputText { text } + | codex_protocol::models::ContentItem::OutputText { text } => { + FunctionCallOutputContentItem::InputText { text } + } + codex_protocol::models::ContentItem::InputImage { image_url, detail } => { + FunctionCallOutputContentItem::InputImage { + image_url, + detail: detail.or(Some(DEFAULT_IMAGE_DETAIL)), + } + } + }) + .collect::>(), + ), + ResponseInputItem::FunctionCallOutput { output, .. } + | ResponseInputItem::CustomToolCallOutput { output, .. } => match output.body { + FunctionCallOutputBody::Text(text) => JsonValue::String(text), + FunctionCallOutputBody::ContentItems(items) => { + content_items_to_code_mode_result(&items) + } + }, + ResponseInputItem::ToolSearchOutput { tools, .. } => JsonValue::Array(tools), + ResponseInputItem::McpToolCallOutput { output, .. } => serde_json::to_value(output) + .unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize mcp result: {err}")) + }), + } +} + +fn content_items_to_code_mode_result(items: &[FunctionCallOutputContentItem]) -> JsonValue { + JsonValue::String( + items + .iter() + .filter_map(|item| match item { + FunctionCallOutputContentItem::InputText { text } if !text.trim().is_empty() => { + Some(text.clone()) + } + FunctionCallOutputContentItem::InputImage { image_url, .. } + if !image_url.trim().is_empty() => + { + Some(image_url.clone()) + } + FunctionCallOutputContentItem::InputText { .. } + | FunctionCallOutputContentItem::InputImage { .. } => None, + }) + .collect::>() + .join("\n"), + ) +} + +fn telemetry_preview(content: &str) -> String { + let truncated_slice = take_bytes_at_char_boundary(content, TELEMETRY_PREVIEW_MAX_BYTES); + let truncated_by_bytes = truncated_slice.len() < content.len(); + + let mut preview = String::new(); + let mut lines_iter = truncated_slice.lines(); + for idx in 0..TELEMETRY_PREVIEW_MAX_LINES { + match lines_iter.next() { + Some(line) => { + if idx > 0 { + preview.push('\n'); + } + preview.push_str(line); + } + None => break, + } + } + let truncated_by_lines = lines_iter.next().is_some(); + + if !truncated_by_bytes && !truncated_by_lines { + return content.to_string(); + } + + if preview.len() < truncated_slice.len() + && truncated_slice + .as_bytes() + .get(preview.len()) + .is_some_and(|byte| *byte == b'\n') + { + preview.push('\n'); + } + + if !preview.is_empty() && !preview.ends_with('\n') { + preview.push('\n'); + } + preview.push_str(TELEMETRY_PREVIEW_TRUNCATION_NOTICE); + + preview +} diff --git a/codex-rs/tools/src/tool_payload.rs b/codex-rs/tools/src/tool_payload.rs new file mode 100644 index 0000000000..71af457743 --- /dev/null +++ b/codex-rs/tools/src/tool_payload.rs @@ -0,0 +1,21 @@ +use std::borrow::Cow; + +use codex_protocol::models::SearchToolCallParams; + +/// Canonical payload shapes accepted by model-visible tool runtimes. +#[derive(Clone, Debug)] +pub enum ToolPayload { + Function { arguments: String }, + ToolSearch { arguments: SearchToolCallParams }, + Custom { input: String }, +} + +impl ToolPayload { + pub fn log_payload(&self) -> Cow<'_, str> { + match self { + ToolPayload::Function { arguments } => Cow::Borrowed(arguments), + ToolPayload::ToolSearch { arguments } => Cow::Owned(arguments.query.clone()), + ToolPayload::Custom { input } => Cow::Borrowed(input), + } + } +} diff --git a/codex-rs/tools/src/tool_spec.rs b/codex-rs/tools/src/tool_spec.rs index 2a0183a905..98f705363c 100644 --- a/codex-rs/tools/src/tool_spec.rs +++ b/codex-rs/tools/src/tool_spec.rs @@ -25,8 +25,6 @@ pub enum ToolSpec { description: String, parameters: JsonSchema, }, - #[serde(rename = "local_shell")] - LocalShell {}, #[serde(rename = "image_generation")] ImageGeneration { output_format: String }, // TODO: Understand why we get an error on web_search although the API docs @@ -58,7 +56,6 @@ impl ToolSpec { ToolSpec::Function(tool) => tool.name.as_str(), ToolSpec::Namespace(namespace) => namespace.name.as_str(), ToolSpec::ToolSearch { .. } => "tool_search", - ToolSpec::LocalShell {} => "local_shell", ToolSpec::ImageGeneration { .. } => "image_generation", ToolSpec::WebSearch { .. } => "web_search", ToolSpec::Freeform(tool) => tool.name.as_str(), diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs index 6d2017e553..a181cd247b 100644 --- a/codex-rs/tools/src/tool_spec_tests.rs +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -57,7 +57,6 @@ fn tool_spec_name_covers_all_variants() { .name(), "tool_search" ); - assert_eq!(ToolSpec::LocalShell {}.name(), "local_shell"); assert_eq!( ToolSpec::ImageGeneration { output_format: "png".to_string(), diff --git a/codex-rs/tui/BUILD.bazel b/codex-rs/tui/BUILD.bazel index d0aab0805a..07495c4308 100644 --- a/codex-rs/tui/BUILD.bazel +++ b/codex-rs/tui/BUILD.bazel @@ -18,7 +18,7 @@ codex_rust_crate( test_data_extra = glob([ "src/**/*.rs", "src/**/snapshots/**", - ]) + ["//codex-rs/core:model_availability_nux_fixtures"], + ]), integration_compile_data_extra = ["src/test_backend.rs"], extra_binaries = [ "//codex-rs/cli:codex", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 92062f88ab..f539db5f5c 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -152,6 +152,7 @@ arboard = { workspace = true } [dev-dependencies] codex-cli = { workspace = true } codex-mcp = { workspace = true } +core_test_support = { workspace = true } codex-utils-cargo-bin = { workspace = true } codex-utils-pty = { workspace = true } assert_matches = { workspace = true } @@ -162,3 +163,4 @@ rand = { workspace = true } serial_test = { workspace = true } vt100 = { workspace = true } uuid = { workspace = true } +wiremock = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 81515603db..62b29f5b3e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -80,6 +80,7 @@ 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::RemoteAppServerEndpoint; use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AskForApproval; @@ -498,8 +499,7 @@ pub(crate) struct App { pub(crate) feedback: codex_feedback::CodexFeedback, feedback_audience: FeedbackAudience, environment_manager: Arc, - remote_app_server_url: Option, - remote_app_server_auth_token: Option, + remote_app_server_endpoint: Option, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -637,8 +637,7 @@ impl App { is_first_run: bool, entered_trust_nux: bool, should_prompt_windows_sandbox_nux_at_startup: bool, - remote_app_server_url: Option, - remote_app_server_auth_token: Option, + remote_app_server_endpoint: Option, state_db: Option, environment_manager: Arc, startup_hooks_browser: Option, @@ -919,8 +918,7 @@ See the Codex keymap documentation for supported actions and examples." feedback: feedback.clone(), feedback_audience, environment_manager, - remote_app_server_url, - remote_app_server_auth_token, + remote_app_server_endpoint, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index e1ae3099a6..1d2df3db16 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -57,11 +57,8 @@ impl App { AppEvent::OpenResumePicker => { let picker_app_server = match crate::start_app_server_for_picker( &self.config, - &match self.remote_app_server_url.clone() { - Some(websocket_url) => crate::AppServerTarget::Remote { - websocket_url, - auth_token: self.remote_app_server_auth_token.clone(), - }, + &match self.remote_app_server_endpoint.clone() { + Some(endpoint) => crate::AppServerTarget::Remote { endpoint }, None => crate::AppServerTarget::Embedded, }, self.state_db.clone(), diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 05aba144c8..75af2f1da9 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -633,7 +633,7 @@ impl App { } let current_cwd = self.config.cwd.to_path_buf(); - let resume_cwd = if self.remote_app_server_url.is_some() { + let resume_cwd = if self.remote_app_server_endpoint.is_some() { current_cwd.clone() } else { match crate::session_resume::resolve_cwd_for_resume_or_fork( diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index f34e22203f..b52402ab49 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -44,8 +44,7 @@ pub(super) async fn make_test_app() -> App { feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), - remote_app_server_url: None, - remote_app_server_auth_token: None, + remote_app_server_endpoint: None, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 6d1e4d31c6..ec20fffea3 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -304,6 +304,18 @@ async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread( assert!(app.transcript_cells.is_empty()); } +#[test] +fn bypass_hook_trust_startup_warning_snapshot() { + let rendered = lines_to_single_string( + &history_cell::new_warning_event( + "`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation." + .to_string(), + ) + .display_lines(/*width*/ 80), + ); + + assert_app_snapshot!("bypass_hook_trust_startup_warning", rendered); +} #[tokio::test] async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -3992,8 +4004,7 @@ async fn make_test_app() -> App { feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), - remote_app_server_url: None, - remote_app_server_auth_token: None, + remote_app_server_endpoint: None, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), @@ -4055,8 +4066,7 @@ async fn make_test_app_with_channels() -> ( feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), - remote_app_server_url: None, - remote_app_server_auth_token: None, + remote_app_server_endpoint: None, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 3702849fce..7fe180a16f 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -29,6 +29,8 @@ use crate::app::app_server_requests::ResolvedAppServerRequest; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListKeymap; use crate::render::Insets; use crate::render::RectExt as _; use crate::style::user_message_style; @@ -238,10 +240,24 @@ pub(crate) struct AppLinkView { screen: AppLinkScreen, selected_action: usize, complete: bool, + list_keymap: ListKeymap, } impl AppLinkView { + #[cfg(test)] pub(crate) fn new(params: AppLinkViewParams, app_event_tx: AppEventSender) -> Self { + Self::new_with_keymap( + params, + app_event_tx, + crate::keymap::RuntimeKeymap::defaults().list, + ) + } + + pub(crate) fn new_with_keymap( + params: AppLinkViewParams, + app_event_tx: AppEventSender, + list_keymap: ListKeymap, + ) -> Self { let AppLinkViewParams { app_id, title, @@ -269,6 +285,7 @@ impl AppLinkView { screen: AppLinkScreen::Link, selected_action: 0, complete: false, + list_keymap, } } @@ -679,10 +696,6 @@ impl BottomPaneView for AppLinkView { KeyEvent { code: KeyCode::Up, .. } - | KeyEvent { - code: KeyCode::Left, - .. - } | KeyEvent { code: KeyCode::BackTab, .. @@ -691,20 +704,12 @@ impl BottomPaneView for AppLinkView { code: KeyCode::Char('k'), modifiers: KeyModifiers::NONE, .. - } - | KeyEvent { - code: KeyCode::Char('h'), - modifiers: KeyModifiers::NONE, - .. } => self.move_selection_prev(), + _ if self.list_keymap.move_left.is_pressed(key_event) => self.move_selection_prev(), KeyEvent { code: KeyCode::Down, .. } - | KeyEvent { - code: KeyCode::Right, - .. - } | KeyEvent { code: KeyCode::Tab, .. } @@ -712,12 +717,8 @@ impl BottomPaneView for AppLinkView { code: KeyCode::Char('j'), modifiers: KeyModifiers::NONE, .. - } - | KeyEvent { - code: KeyCode::Char('l'), - modifiers: KeyModifiers::NONE, - .. } => self.move_selection_next(), + _ if self.list_keymap.move_right.is_pressed(key_event) => self.move_selection_next(), KeyEvent { code: KeyCode::Char(c), modifiers: KeyModifiers::NONE, @@ -1087,6 +1088,74 @@ mod tests { assert!(view.terminal_title_requires_action()); } + #[test] + fn horizontal_list_keys_move_action_selection() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: "https://example.test/notion".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, + }, + tx, + ); + + assert_eq!(view.selected_action, 0); + view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert_eq!(view.selected_action, 1); + view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(view.selected_action, 0); + } + + #[test] + fn remapped_horizontal_list_keys_control_action_selection() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut list_keymap = crate::keymap::RuntimeKeymap::defaults().list; + list_keymap.move_left = vec![key_hint::plain(KeyCode::Char('x'))]; + list_keymap.move_right = vec![key_hint::plain(KeyCode::Char('z'))]; + let mut view = AppLinkView::new_with_keymap( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: "https://example.test/notion".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, + }, + tx, + list_keymap, + ); + + assert_eq!(view.selected_action, 0); + view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)); + assert_eq!(view.selected_action, 0); + view.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(view.selected_action, 0); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); + assert_eq!(view.selected_action, 1); + view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + assert_eq!(view.selected_action, 1); + view.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(view.selected_action, 1); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!(view.selected_action, 0); + } + #[test] fn toggle_action_sends_set_app_enabled_and_updates_label() { let (tx_raw, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5f93febfa7..0f97b7f6c9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -415,7 +415,6 @@ pub(crate) struct ChatComposer { audio_device_selection_enabled: bool, windows_degraded_sandbox_active: bool, side_conversation_active: bool, - is_zellij: bool, status_line_value: Option>, status_line_hyperlink_url: Option, status_line_enabled: bool, @@ -599,10 +598,6 @@ impl ChatComposer { audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, side_conversation_active: false, - is_zellij: matches!( - codex_terminal_detection::terminal_info().multiplexer, - Some(codex_terminal_detection::Multiplexer::Zellij {}) - ), status_line_value: None, status_line_hyperlink_url: None, status_line_enabled: false, @@ -4788,56 +4783,20 @@ impl ChatComposer { } } } - self.render_textarea( - composer_rect, - remote_images_rect, - textarea_rect, - buf, - mask_char, - ); - } - - /// Paint the composer's text input area, prompt chevron, and placeholder text. - /// - /// In Zellij sessions the textarea uses explicit `Color::Reset` foreground styling - /// to prevent the multiplexer's pane chrome from bleeding into cell styles, and - /// substitutes hardcoded colors for `.bold()` / `.dim()` modifiers that Zellij - /// renders inconsistently. The standard path is unchanged. - fn render_textarea( - &self, - composer_rect: Rect, - remote_images_rect: Rect, - textarea_rect: Rect, - buf: &mut Buffer, - mask_char: Option, - ) { - let is_zellij = self.is_zellij; let style = user_message_style(); - let textarea_style = style.fg(ratatui::style::Color::Reset); Block::default().style(style).render_ref(composer_rect, buf); if !remote_images_rect.is_empty() { Paragraph::new(self.remote_images_lines(remote_images_rect.width)) .style(style) .render_ref(remote_images_rect, buf); } - if is_zellij && !textarea_rect.is_empty() { - buf.set_style(textarea_rect, textarea_style); - } if !textarea_rect.is_empty() { let prompt = if self.input_enabled { if self.is_bash_mode { - if is_zellij { - Span::from("!").light_red() - } else { - Span::from("!").light_red().bold() - } - } else if is_zellij { - Span::styled("›", style.fg(ratatui::style::Color::Cyan)) + Span::from("!").light_red().bold() } else { "›".bold() } - } else if is_zellij { - Span::styled("›", style.fg(ratatui::style::Color::DarkGray)) } else { "›".dim() }; @@ -4852,39 +4811,8 @@ impl ChatComposer { let mut state = self.textarea_state.borrow_mut(); let textarea_is_empty = self.textarea.text().is_empty() && !self.is_bash_mode; if let Some(mask_char) = mask_char { - self.textarea.render_ref_masked( - textarea_rect, - buf, - &mut state, - mask_char, - if is_zellij { - textarea_style - } else { - ratatui::style::Style::default() - }, - ); - } else if is_zellij && textarea_is_empty { - buf.set_style(textarea_rect, textarea_style); - } else if is_zellij { - let highlight_ranges = self.history_search_highlight_ranges(); - if highlight_ranges.is_empty() { - self.textarea - .render_ref_styled(textarea_rect, buf, &mut state, textarea_style); - } else { - let highlight_style = - textarea_style.add_modifier(Modifier::REVERSED | Modifier::BOLD); - let highlights = highlight_ranges - .into_iter() - .map(|range| (range, highlight_style)) - .collect::>(); - self.textarea.render_ref_styled_with_highlights( - textarea_rect, - buf, - &mut state, - textarea_style, - &highlights, - ); - } + self.textarea + .render_ref_masked(textarea_rect, buf, &mut state, mask_char); } else { let highlight_ranges = self.history_search_highlight_ranges(); if highlight_ranges.is_empty() { @@ -4915,18 +4843,9 @@ impl ChatComposer { .to_string() }; if !textarea_rect.is_empty() { - if is_zellij { - buf.set_string( - textarea_rect.x, - textarea_rect.y, - text, - textarea_style.fg(ratatui::style::Color::White).italic(), - ); - } else { - let placeholder = Span::from(text).dim(); - let line = Line::from(vec![placeholder]); - line.render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); - } + let placeholder = Span::from(text).dim(); + Line::from(vec![placeholder]) + .render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); } } } @@ -5149,35 +5068,6 @@ mod tests { ); } - fn snapshot_zellij_composer_state(name: &str, setup: F) - where - F: FnOnce(&mut ChatComposer), - { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - 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, - ); - composer.is_zellij = true; - setup(&mut composer); - let footer_props = composer.footer_props(); - let footer_lines = footer_height(&footer_props); - let footer_spacing = ChatComposer::footer_spacing(footer_lines); - let height = footer_lines + footer_spacing + 8; - let mut terminal = Terminal::new(TestBackend::new(100, height)).unwrap(); - terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) - .unwrap(); - insta::assert_snapshot!(name, terminal.backend()); - } - #[test] fn footer_mode_snapshots() { use crossterm::event::KeyCode; @@ -5697,11 +5587,6 @@ mod tests { ); } - #[test] - fn zellij_empty_composer_snapshot() { - snapshot_zellij_composer_state("zellij_empty_composer", |_composer| {}); - } - #[test] fn esc_hint_stays_hidden_with_draft_content() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs index c36d70c9fb..6d57e58b6c 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs @@ -13,6 +13,8 @@ use ratatui::widgets::Widget; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListKeymap; use crate::render::Insets; use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; @@ -43,12 +45,14 @@ pub(crate) struct ExperimentalFeaturesView { app_event_tx: AppEventSender, header: Box, footer_hint: Line<'static>, + keymap: ListKeymap, } impl ExperimentalFeaturesView { pub(crate) fn new( features: Vec, app_event_tx: AppEventSender, + keymap: ListKeymap, ) -> Self { let mut header = ColumnRenderable::new(); header.push(Line::from("Experimental features".bold())); @@ -63,6 +67,7 @@ impl ExperimentalFeaturesView { app_event_tx, header: Box::new(header), footer_hint: experimental_popup_hint_line(), + keymap, }; view.initialize_selection(); view @@ -119,6 +124,30 @@ impl ExperimentalFeaturesView { self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } + fn page_up(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.state.page_up_clamped(len, visible); + } + + fn page_down(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.state.page_down_clamped(len, visible); + } + + fn jump_top(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.state.jump_top(len, visible); + } + + fn jump_bottom(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.state.jump_bottom(len, visible); + } + fn toggle_selected(&mut self) { let Some(selected_idx) = self.state.selected_idx else { return; @@ -137,56 +166,20 @@ impl ExperimentalFeaturesView { impl BottomPaneView for ExperimentalFeaturesView { fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{0010}'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_up(), - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_up(), - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{000e}'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_down(), - KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_down(), + _ if self.keymap.move_up.is_pressed(key_event) => self.move_up(), + _ if self.keymap.move_down.is_pressed(key_event) => self.move_down(), + _ if self.keymap.page_up.is_pressed(key_event) => self.page_up(), + _ if self.keymap.page_down.is_pressed(key_event) => self.page_down(), + _ if self.keymap.jump_top.is_pressed(key_event) => self.jump_top(), + _ if self.keymap.jump_bottom.is_pressed(key_event) => self.jump_bottom(), KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. } => self.toggle_selected(), - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } - | KeyEvent { - code: KeyCode::Esc, .. - } => { + _ if self.keymap.accept.is_pressed(key_event) + || self.keymap.cancel.is_pressed(key_event) => + { self.on_ctrl_c(); } _ => {} diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index c9c7a8c617..462e9b286e 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -29,6 +29,8 @@ use crate::app_event_sender::AppEventSender; use crate::hooks_rpc::HookTrustUpdate; use crate::hooks_rpc::hook_needs_review; use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListKeymap; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::renderable::Renderable; use crate::status::format_directory_display; @@ -50,6 +52,7 @@ pub(crate) struct HooksBrowserView { state: ScrollState, complete: bool, app_event_tx: AppEventSender, + keymap: ListKeymap, } impl HooksBrowserView { @@ -68,10 +71,15 @@ impl HooksBrowserView { errors, }, app_event_tx, + crate::keymap::RuntimeKeymap::defaults().list, ) } - pub(crate) fn from_entry(mut entry: HooksListEntry, app_event_tx: AppEventSender) -> Self { + pub(crate) fn from_entry( + mut entry: HooksListEntry, + app_event_tx: AppEventSender, + keymap: ListKeymap, + ) -> Self { entry.hooks.sort_by_key(|hook| hook.display_order); let mut view = Self { entry, @@ -79,6 +87,7 @@ impl HooksBrowserView { state: ScrollState::new(), complete: false, app_event_tx, + keymap, }; if view.page_len() > 0 { view.state.selected_idx = Some( @@ -165,6 +174,26 @@ impl HooksBrowserView { self.state.ensure_visible(len, self.max_visible_rows()); } + fn page_up(&mut self) { + let len = self.page_len(); + self.state.page_up_clamped(len, self.max_visible_rows()); + } + + fn page_down(&mut self) { + let len = self.page_len(); + self.state.page_down_clamped(len, self.max_visible_rows()); + } + + fn jump_top(&mut self) { + let len = self.page_len(); + self.state.jump_top(len, self.max_visible_rows()); + } + + fn jump_bottom(&mut self) { + let len = self.page_len(); + self.state.jump_bottom(len, self.max_visible_rows()); + } + fn page_len(&self) -> usize { match self.page { HooksBrowserPage::Events => codex_protocol::protocol::HookEventName::iter().count(), @@ -533,33 +562,18 @@ impl HooksBrowserView { impl BottomPaneView for HooksBrowserView { fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { - KeyEvent { - code: KeyCode::Up, .. + _ if self.keymap.move_up.is_pressed(key_event) => self.move_up(), + _ if self.keymap.move_down.is_pressed(key_event) => self.move_down(), + _ if self.keymap.page_up.is_pressed(key_event) => self.page_up(), + _ if self.keymap.page_down.is_pressed(key_event) => self.page_down(), + _ if self.keymap.jump_top.is_pressed(key_event) => self.jump_top(), + _ if self.keymap.jump_bottom.is_pressed(key_event) => self.jump_bottom(), + _ if self.keymap.accept.is_pressed(key_event) + && self.page == HooksBrowserPage::Events => + { + self.open_selected_event() } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } => self.move_up(), - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => self.move_down(), - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } if self.page == HooksBrowserPage::Events => self.open_selected_event(), - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { + _ if self.keymap.accept.is_pressed(key_event) => { if let HooksBrowserPage::Handlers(event_name) = self.page { self.toggle_selected_hook(event_name); } @@ -581,9 +595,7 @@ impl BottomPaneView for HooksBrowserView { HooksBrowserPage::Events => self.trust_all_hooks(), HooksBrowserPage::Handlers(event_name) => self.trust_selected_hook(event_name), }, - KeyEvent { - code: KeyCode::Esc, .. - } => match self.page { + _ if self.keymap.cancel.is_pressed(key_event) => match self.page { HooksBrowserPage::Events => self.close(), HooksBrowserPage::Handlers(_) => self.return_to_events(), }, diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 2d0ac3717b..94ef15cde5 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -17,6 +17,7 @@ use super::selection_popup_common::wrap_styled_line; use crate::app_event_sender::AppEventSender; use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; +use crate::key_hint::is_plain_text_key_event; use crate::keymap::ListKeymap; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; @@ -655,8 +656,8 @@ impl ListSelectionView { let len = self.visible_len(); self.state.move_up_wrap(len); let visible = Self::max_visible_rows(len); - self.state.ensure_visible(len, visible); self.skip_disabled_up(); + self.state.ensure_visible(len, visible); if self.selected_actual_idx() != before { self.fire_selection_changed(); } @@ -667,8 +668,56 @@ impl ListSelectionView { let len = self.visible_len(); self.state.move_down_wrap(len); let visible = Self::max_visible_rows(len); - self.state.ensure_visible(len, visible); self.skip_disabled_down(); + self.state.ensure_visible(len, visible); + if self.selected_actual_idx() != before { + self.fire_selection_changed(); + } + } + + fn page_up(&mut self) { + let before = self.selected_actual_idx(); + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.page_up_clamped(len, visible); + self.skip_disabled_up_clamped(); + self.state.ensure_visible(len, visible); + if self.selected_actual_idx() != before { + self.fire_selection_changed(); + } + } + + fn page_down(&mut self) { + let before = self.selected_actual_idx(); + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.page_down_clamped(len, visible); + self.skip_disabled_down_clamped(); + self.state.ensure_visible(len, visible); + if self.selected_actual_idx() != before { + self.fire_selection_changed(); + } + } + + fn jump_top(&mut self) { + let before = self.selected_actual_idx(); + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.jump_top(len, visible); + self.skip_disabled_down_clamped(); + self.state.ensure_visible(len, visible); + if self.selected_actual_idx() != before { + self.fire_selection_changed(); + } + } + + fn jump_bottom(&mut self) { + let before = self.selected_actual_idx(); + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.jump_bottom(len, visible); + self.skip_disabled_up_clamped(); + self.state.ensure_visible(len, visible); if self.selected_actual_idx() != before { self.fire_selection_changed(); } @@ -788,13 +837,7 @@ impl ListSelectionView { fn skip_disabled_down(&mut self) { let len = self.visible_len(); for _ in 0..len { - if let Some(idx) = self.state.selected_idx - && let Some(actual_idx) = self.filtered_indices.get(idx) - && self - .active_items() - .get(*actual_idx) - .is_some_and(|item| item.disabled_reason.is_some() || item.is_disabled) - { + if self.selected_visible_idx_is_disabled() { self.state.move_down_wrap(len); } else { break; @@ -805,34 +848,70 @@ impl ListSelectionView { fn skip_disabled_up(&mut self) { let len = self.visible_len(); for _ in 0..len { - if let Some(idx) = self.state.selected_idx - && let Some(actual_idx) = self.filtered_indices.get(idx) - && self - .active_items() - .get(*actual_idx) - .is_some_and(|item| item.disabled_reason.is_some() || item.is_disabled) - { + if self.selected_visible_idx_is_disabled() { self.state.move_up_wrap(len); } else { break; } } } + + fn skip_disabled_down_clamped(&mut self) { + let Some(start) = self.state.selected_idx else { + return; + }; + if !self.visible_idx_is_disabled(start) { + return; + } + + let len = self.visible_len(); + self.state.selected_idx = ((start + 1)..len) + .find(|idx| !self.visible_idx_is_disabled(*idx)) + .or_else(|| { + (0..start) + .rev() + .find(|idx| !self.visible_idx_is_disabled(*idx)) + }) + .or(Some(start)); + } + + fn skip_disabled_up_clamped(&mut self) { + let Some(start) = self.state.selected_idx else { + return; + }; + if !self.visible_idx_is_disabled(start) { + return; + } + + let len = self.visible_len(); + self.state.selected_idx = (0..start) + .rev() + .find(|idx| !self.visible_idx_is_disabled(*idx)) + .or_else(|| ((start + 1)..len).find(|idx| !self.visible_idx_is_disabled(*idx))) + .or(Some(start)); + } + + fn selected_visible_idx_is_disabled(&self) -> bool { + self.state + .selected_idx + .is_some_and(|idx| self.visible_idx_is_disabled(idx)) + } + + fn visible_idx_is_disabled(&self, idx: usize) -> bool { + self.filtered_indices + .get(idx) + .and_then(|actual_idx| self.active_items().get(*actual_idx)) + .is_some_and(|item| item.disabled_reason.is_some() || item.is_disabled) + } } impl BottomPaneView for ListSelectionView { fn handle_key_event(&mut self, key_event: KeyEvent) { - let is_plain_text_char = matches!( - key_event, - KeyEvent { - code: KeyCode::Char(ch), - modifiers, - .. - } if !ch.is_ascii_control() - && !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) - ); - let allow_plain_char_navigation = !self.is_searchable || !is_plain_text_char; + // Searchable lists reserve printable characters for query input. This + // keeps vim-style plain j/k/h/l useful in non-search lists without + // making those letters impossible to type into a filter. + let allow_plain_char_navigation = + !self.is_searchable || !is_plain_text_key_event(key_event); match key_event { _ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => { @@ -841,14 +920,30 @@ impl BottomPaneView for ListSelectionView { _ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => { self.move_down() } - KeyEvent { - code: KeyCode::Left, - .. - } if self.tabs_enabled() => self.switch_tab(/*step*/ -1), - KeyEvent { - code: KeyCode::Right, - .. - } if self.tabs_enabled() => self.switch_tab(/*step*/ 1), + _ if allow_plain_char_navigation && self.keymap.page_up.is_pressed(key_event) => { + self.page_up() + } + _ if allow_plain_char_navigation && self.keymap.page_down.is_pressed(key_event) => { + self.page_down() + } + _ if allow_plain_char_navigation && self.keymap.jump_top.is_pressed(key_event) => { + self.jump_top() + } + _ if allow_plain_char_navigation && self.keymap.jump_bottom.is_pressed(key_event) => { + self.jump_bottom() + } + _ if allow_plain_char_navigation + && self.tabs_enabled() + && self.keymap.move_left.is_pressed(key_event) => + { + self.switch_tab(/*step*/ -1) + } + _ if allow_plain_char_navigation + && self.tabs_enabled() + && self.keymap.move_right.is_pressed(key_event) => + { + self.switch_tab(/*step*/ 1) + } KeyEvent { code: KeyCode::Backspace, .. @@ -1618,6 +1713,10 @@ mod tests { assert_eq!(view.active_tab_id(), Some("alpha")); assert_eq!(view.search_query, ""); + view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert_eq!(view.active_tab_id(), Some("beta")); + view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(view.active_tab_id(), Some("alpha")); let rendered = render_lines(&view); assert!( rendered.contains("Alpha Item") && !rendered.contains("Beta Item"), @@ -2059,6 +2158,77 @@ mod tests { assert_eq!(view.selected_actual_idx(), Some(1)); } + #[test] + fn page_and_jump_navigation_use_list_keymap() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults().list; + keymap.page_down = vec![crate::key_hint::ctrl(KeyCode::Char('d'))]; + keymap.page_up = vec![crate::key_hint::ctrl(KeyCode::Char('u'))]; + keymap.jump_bottom = vec![crate::key_hint::ctrl(KeyCode::Char('e'))]; + keymap.jump_top = vec![crate::key_hint::ctrl(KeyCode::Char('a'))]; + let mut view = ListSelectionView::new( + SelectionViewParams { + items: (0..12) + .map(|idx| SelectionItem { + name: format!("Item {idx}"), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + tx, + keymap, + ); + + view.handle_key_event(KeyEvent::from(KeyCode::PageDown)); + assert_eq!(view.selected_actual_idx(), Some(0)); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); + assert_eq!(view.selected_actual_idx(), Some(8)); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert_eq!(view.selected_actual_idx(), Some(0)); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)); + assert_eq!(view.selected_actual_idx(), Some(11)); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); + assert_eq!(view.selected_actual_idx(), Some(0)); + } + + #[test] + fn page_and_jump_navigation_skip_trailing_disabled_rows_without_wrapping() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ListSelectionView::new( + SelectionViewParams { + items: (0..12) + .map(|idx| SelectionItem { + name: format!("Item {idx}"), + is_disabled: idx >= 8, + ..Default::default() + }) + .collect(), + ..Default::default() + }, + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); + + view.handle_key_event(KeyEvent::from(KeyCode::PageDown)); + assert_eq!(view.selected_actual_idx(), Some(7)); + let selected = view.state.selected_idx.expect("selection should be set"); + assert!(view.state.scroll_top <= selected); + assert!(selected < view.state.scroll_top + ListSelectionView::max_visible_rows(/*len*/ 12)); + + view.handle_key_event(KeyEvent::from(KeyCode::End)); + assert_eq!(view.selected_actual_idx(), Some(7)); + let selected = view.state.selected_idx.expect("selection should be set"); + assert!(view.state.scroll_top <= selected); + assert!(selected < view.state.scroll_top + ListSelectionView::max_visible_rows(/*len*/ 12)); + } + #[test] fn wraps_long_option_without_overflowing_columns() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 95e8e8ca1e..447ab8e6c3 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -49,6 +49,8 @@ use crate::bottom_pane::selection_popup_common::menu_surface_inset; use crate::bottom_pane::selection_popup_common::menu_surface_padding_height; use crate::bottom_pane::selection_popup_common::render_menu_surface; use crate::bottom_pane::selection_popup_common::render_rows; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListKeymap; use crate::render::renderable::Renderable; use crate::text_formatting::format_json_compact; use crate::text_formatting::truncate_text; @@ -711,15 +713,35 @@ pub(crate) struct McpServerElicitationOverlay { current_idx: usize, done: bool, validation_error: Option, + list_keymap: ListKeymap, } impl McpServerElicitationOverlay { + #[cfg(test)] pub(crate) fn new( request: McpServerElicitationFormRequest, app_event_tx: AppEventSender, has_input_focus: bool, enhanced_keys_supported: bool, disable_paste_burst: bool, + ) -> Self { + Self::new_with_keymap( + request, + app_event_tx, + has_input_focus, + enhanced_keys_supported, + disable_paste_burst, + crate::keymap::RuntimeKeymap::defaults().list, + ) + } + + pub(crate) fn new_with_keymap( + request: McpServerElicitationFormRequest, + app_event_tx: AppEventSender, + has_input_focus: bool, + enhanced_keys_supported: bool, + disable_paste_burst: bool, + list_keymap: ListKeymap, ) -> Self { let mut composer = ChatComposer::new_with_config( has_input_focus, @@ -739,6 +761,7 @@ impl McpServerElicitationOverlay { current_idx: 0, done: false, validation_error: None, + list_keymap, }; overlay.reset_for_request(); overlay.restore_current_draft(); @@ -1529,18 +1552,40 @@ impl BottomPaneView for McpServerElicitationOverlay { code: KeyCode::Left, modifiers: KeyModifiers::NONE, .. + } + | KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::NONE, + .. } if self.current_field_is_select() => { self.move_field(/*next*/ false); return; } + _ if self.current_field_is_select() + && self.list_keymap.move_left.is_pressed(key_event) => + { + self.move_field(/*next*/ false); + return; + } KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::NONE, .. + } + | KeyEvent { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::NONE, + .. } if self.current_field_is_select() => { self.move_field(/*next*/ true); return; } + _ if self.current_field_is_select() + && self.list_keymap.move_right.is_pressed(key_event) => + { + self.move_field(/*next*/ true); + return; + } _ => {} } @@ -2155,6 +2200,43 @@ mod tests { ); } + #[test] + fn horizontal_list_keys_move_between_select_fields() { + let (tx, _rx) = test_sender(); + let request = from_form_request( + ThreadId::default(), + form_request( + "Choose values", + serde_json::json!({ + "type": "object", + "properties": { + "first": { + "type": "boolean", + "title": "First", + }, + "second": { + "type": "boolean", + "title": "Second", + } + }, + "required": ["first", "second"], + }), + /*meta*/ None, + ), + ) + .expect("expected supported form"); + let mut overlay = McpServerElicitationOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + assert_eq!(overlay.current_idx, 0); + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert_eq!(overlay.current_idx, 1); + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(overlay.current_idx, 0); + } + #[test] fn empty_tool_approval_schema_session_choice_sets_persist_meta() { let (tx, mut rx) = test_sender(); diff --git a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs index d2d3c00ce3..06e9b31eb8 100644 --- a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs +++ b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs @@ -14,6 +14,8 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListKeymap; use crate::render::Insets; use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; @@ -62,6 +64,7 @@ pub(crate) struct MemoriesSettingsView { complete: bool, app_event_tx: AppEventSender, docs_link: Line<'static>, + keymap: ListKeymap, } impl MemoriesSettingsView { @@ -69,6 +72,7 @@ impl MemoriesSettingsView { use_memories: bool, generate_memories: bool, app_event_tx: AppEventSender, + keymap: ListKeymap, ) -> Self { let mut view = Self { items: vec![ @@ -98,6 +102,7 @@ impl MemoriesSettingsView { "Learn more: ".dim(), MEMORIES_DOC_URL.cyan().underlined(), ]), + keymap, }; view.initialize_selection(); view @@ -216,6 +221,30 @@ impl MemoriesSettingsView { state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } + fn page_up(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.active_state_mut().page_up_clamped(len, visible); + } + + fn page_down(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.active_state_mut().page_down_clamped(len, visible); + } + + fn jump_top(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.active_state_mut().jump_top(len, visible); + } + + fn jump_bottom(&mut self) { + let len = self.visible_len(); + let visible = MAX_POPUP_ROWS.min(len); + self.active_state_mut().jump_bottom(len, visible); + } + fn toggle_selected(&mut self) { if self.reset_confirmation.is_some() { return; @@ -271,56 +300,19 @@ impl MemoriesSettingsView { impl BottomPaneView for MemoriesSettingsView { fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{0010}'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_up(), - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_up(), - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{000e}'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_down(), - KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_down(), + _ if self.keymap.move_up.is_pressed(key_event) => self.move_up(), + _ if self.keymap.move_down.is_pressed(key_event) => self.move_down(), + _ if self.keymap.page_up.is_pressed(key_event) => self.page_up(), + _ if self.keymap.page_down.is_pressed(key_event) => self.page_down(), + _ if self.keymap.jump_top.is_pressed(key_event) => self.jump_top(), + _ if self.keymap.jump_bottom.is_pressed(key_event) => self.jump_bottom(), KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. } => self.toggle_selected(), - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => self.save(), - KeyEvent { - code: KeyCode::Esc, .. - } => self.cancel(), + _ if self.keymap.accept.is_pressed(key_event) => self.save(), + _ if self.keymap.cancel.is_pressed(key_event) => self.cancel(), _ => {} } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5a0f967ee2..3faea3f81c 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1082,6 +1082,10 @@ impl BottomPane { popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list) } + pub(crate) fn list_keymap(&self) -> crate::keymap::ListKeymap { + self.keymap.list.clone() + } + /// Replace one or more active views whose IDs are in `view_ids` with a /// generic list selection view. pub(crate) fn replace_active_views_with_selection_view( @@ -1298,12 +1302,13 @@ impl BottomPane { request }; - let modal = RequestUserInputOverlay::new( + let modal = RequestUserInputOverlay::new_with_keymap( request, self.app_event_tx.clone(), self.has_input_focus, self.enhanced_keys_supported, self.disable_paste_burst, + self.keymap.list.clone(), ); self.pause_status_timer_for_modal(); self.set_composer_input_enabled( @@ -1342,7 +1347,7 @@ impl BottomPane { tool_suggestion.suggest_type, mcp_server_elicitation::ToolSuggestionType::Enable ); - let view = AppLinkView::new( + let view = AppLinkView::new_with_keymap( AppLinkViewParams { app_id: tool_suggestion.tool_id.clone(), title: tool_suggestion.tool_name.clone(), @@ -1373,6 +1378,7 @@ impl BottomPane { }), }, self.app_event_tx.clone(), + self.keymap.list.clone(), ); self.pause_status_timer_for_modal(); self.set_composer_input_enabled( @@ -1383,12 +1389,13 @@ impl BottomPane { return; } - let modal = McpServerElicitationOverlay::new( + let modal = McpServerElicitationOverlay::new_with_keymap( request, self.app_event_tx.clone(), self.has_input_focus, self.enhanced_keys_supported, self.disable_paste_burst, + self.keymap.list.clone(), ); self.pause_status_timer_for_modal(); self.set_composer_input_enabled( diff --git a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs index 6b046e7ea0..619ff31b5d 100644 --- a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs +++ b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs @@ -61,6 +61,11 @@ use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS; use crate::bottom_pane::scroll_state::ScrollState; use crate::bottom_pane::selection_popup_common::render_rows_single_line; use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::key_hint::is_plain_text_key_event; +use crate::keymap::ListKeymap; +use crate::keymap::RuntimeKeymap; +use crate::keymap::primary_binding; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::Insets; use crate::render::RectExt; @@ -182,6 +187,9 @@ pub(crate) struct MultiSelectPicker { /// Whether left/right arrow reordering is enabled. ordering_enabled: bool, + /// Shared list keybindings for navigation and completion. + keymap: ListKeymap, + /// Optional callback to generate a preview line from current item states. preview_builder: Option, @@ -352,6 +360,30 @@ impl MultiSelectPicker { self.state.ensure_visible(len, visible); } + fn page_up(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.page_up_clamped(len, visible); + } + + fn page_down(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.page_down_clamped(len, visible); + } + + fn jump_top(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.jump_top(len, visible); + } + + fn jump_bottom(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.jump_bottom(len, visible); + } + /// Toggles the enabled state of the currently selected item. /// /// Updates the preview line and invokes the `on_change` callback if set. @@ -493,50 +525,41 @@ impl BottomPaneView for MultiSelectPicker { } fn handle_key_event(&mut self, key_event: KeyEvent) { + // Printable characters always feed search. Movement aliases such as + // plain j/k only apply through non-text events or modified bindings. + let allow_plain_char_navigation = !is_plain_text_key_event(key_event); + match key_event { - KeyEvent { code: KeyCode::Left, .. } if self.ordering_enabled => { + _ if allow_plain_char_navigation + && self.ordering_enabled + && self.keymap.move_left.is_pressed(key_event) => + { self.move_selected_item(Direction::Up); } - KeyEvent { code: KeyCode::Right, .. } if self.ordering_enabled => { + _ if allow_plain_char_navigation + && self.ordering_enabled + && self.keymap.move_right.is_pressed(key_event) => + { self.move_selected_item(Direction::Down); } - KeyEvent { - code: KeyCode::Up, .. + _ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => { + self.move_up() } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. + _ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => { + self.move_down() } - | KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::CONTROL, - .. + _ if allow_plain_char_navigation && self.keymap.page_up.is_pressed(key_event) => { + self.page_up() } - | KeyEvent { - code: KeyCode::Char('\u{0010}'), - modifiers: KeyModifiers::NONE, - .. - } /* ^P */ => self.move_up(), - KeyEvent { - code: KeyCode::Down, - .. + _ if allow_plain_char_navigation && self.keymap.page_down.is_pressed(key_event) => { + self.page_down() } - | KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::CONTROL, - .. + _ if allow_plain_char_navigation && self.keymap.jump_top.is_pressed(key_event) => { + self.jump_top() } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. + _ if allow_plain_char_navigation && self.keymap.jump_bottom.is_pressed(key_event) => { + self.jump_bottom() } - | KeyEvent { - code: KeyCode::Char('\u{000e}'), - modifiers: KeyModifiers::NONE, - .. - } /* ^N */ => self.move_down(), KeyEvent { code: KeyCode::Backspace, .. @@ -549,15 +572,8 @@ impl BottomPaneView for MultiSelectPicker { modifiers: KeyModifiers::NONE, .. } => self.toggle_selected(), - KeyEvent { - code: KeyCode::Enter, - .. - } => self.confirm_selection(), - KeyEvent { - code: KeyCode::Esc, .. - } => { - self.close(); - } + _ if self.keymap.accept.is_pressed(key_event) => self.confirm_selection(), + _ if self.keymap.cancel.is_pressed(key_event) => self.close(), KeyEvent { code: KeyCode::Char(c), modifiers, @@ -703,6 +719,7 @@ pub(crate) struct MultiSelectPickerBuilder { items: Vec, ordering_enabled: bool, app_event_tx: AppEventSender, + keymap: ListKeymap, preview_builder: Option, on_change: Option, on_confirm: Option, @@ -719,6 +736,7 @@ impl MultiSelectPickerBuilder { items: Vec::new(), ordering_enabled: false, app_event_tx, + keymap: RuntimeKeymap::defaults().list, preview_builder: None, on_change: None, on_confirm: None, @@ -732,15 +750,6 @@ impl MultiSelectPickerBuilder { self } - /// Sets custom instruction spans for the footer hint line. - /// - /// If not set, default instructions are shown (Space to toggle, Enter to - /// confirm, Escape to close). - pub fn instructions(mut self, instructions: Vec>) -> Self { - self.instructions = instructions; - self - } - /// Enables left/right arrow keys for reordering items. /// /// Reordering is only active when the search query is empty. @@ -749,6 +758,12 @@ impl MultiSelectPickerBuilder { self } + /// Sets the shared list keymap used for navigation and completion. + pub fn list_keymap(mut self, keymap: ListKeymap) -> Self { + self.keymap = keymap; + self + } + /// Sets a callback to generate a preview line from the current item states. /// /// The callback receives all items and should return a [`Line`] to display, @@ -806,15 +821,34 @@ impl MultiSelectPickerBuilder { } let instructions = if self.instructions.is_empty() { - vec![ + let mut spans = vec![ "Press ".into(), key_hint::plain(KeyCode::Char(' ')).into(), - " to toggle; ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to confirm and close; ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to close".into(), - ] + " to toggle".into(), + ]; + if self.ordering_enabled + && let (Some(move_left), Some(move_right)) = ( + primary_binding(&self.keymap.move_left), + primary_binding(&self.keymap.move_right), + ) + { + spans.push("; ".into()); + spans.push(move_left.into()); + spans.push("/".into()); + spans.push(move_right.into()); + spans.push(" to move".into()); + } + if let Some(accept) = primary_binding(&self.keymap.accept) { + spans.push("; ".into()); + spans.push(accept.into()); + spans.push(" to confirm and close".into()); + } + if let Some(cancel) = primary_binding(&self.keymap.cancel) { + spans.push("; ".into()); + spans.push(cancel.into()); + spans.push(" to close".into()); + } + spans } else { self.instructions }; @@ -827,6 +861,7 @@ impl MultiSelectPickerBuilder { header: Box::new(header), footer_hint: Line::from(instructions), ordering_enabled: self.ordering_enabled, + keymap: self.keymap, search_query: String::new(), filtered_indices: Vec::new(), preview_builder: self.preview_builder, @@ -940,6 +975,38 @@ mod tests { ); } + #[test] + fn horizontal_list_keys_reorder_orderable_items() { + let mut picker = test_picker(vec![ + item( + "model", /*orderable*/ true, /*section_break_after*/ false, + ), + item( + "branch", /*orderable*/ true, /*section_break_after*/ false, + ), + ]); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert_eq!( + picker + .items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + vec!["branch", "model"] + ); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!( + picker + .items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + vec!["model", "branch"] + ); + } + #[test] fn section_break_after_item_renders_separator_row() { let picker = test_picker(vec![ @@ -964,4 +1031,65 @@ mod tests { ); assert_eq!(rows.state.selected_idx, Some(0)); } + + #[test] + fn searchable_plain_j_updates_query_instead_of_navigating() { + let mut picker = test_picker(vec![ + item( + "alpha", /*orderable*/ true, /*section_break_after*/ false, + ), + item( + "jupiter", /*orderable*/ true, /*section_break_after*/ false, + ), + ]); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + + assert_eq!(picker.search_query, "j"); + assert_eq!(picker.filtered_indices, vec![1]); + assert_eq!(picker.state.selected_idx, Some(0)); + } + + #[test] + fn page_and_jump_navigation_use_list_keymap() { + let (tx, _rx) = unbounded_channel::(); + let mut keymap = RuntimeKeymap::defaults().list; + keymap.page_down = vec![key_hint::ctrl(KeyCode::Char('d'))]; + keymap.page_up = vec![key_hint::ctrl(KeyCode::Char('u'))]; + keymap.jump_bottom = vec![key_hint::ctrl(KeyCode::Char('e'))]; + keymap.jump_top = vec![key_hint::ctrl(KeyCode::Char('a'))]; + let mut picker = MultiSelectPicker::builder( + "Test".to_string(), + /*subtitle*/ None, + AppEventSender::new(tx), + ) + .items( + (0..12) + .map(|idx| { + item( + &format!("item-{idx}"), + /*orderable*/ true, + /*section_break_after*/ false, + ) + }) + .collect(), + ) + .list_keymap(keymap) + .build(); + + picker.handle_key_event(KeyEvent::from(KeyCode::PageDown)); + assert_eq!(picker.state.selected_idx, Some(0)); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); + assert_eq!(picker.state.selected_idx, Some(8)); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert_eq!(picker.state.selected_idx, Some(0)); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)); + assert_eq!(picker.state.selected_idx, Some(11)); + + picker.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); + assert_eq!(picker.state.selected_idx, Some(0)); + } } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 1b9e8c975b..68c6755e45 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -29,6 +29,8 @@ use crate::bottom_pane::scroll_state::ScrollState; use crate::bottom_pane::selection_popup_common::GenericDisplayRow; use crate::bottom_pane::selection_popup_common::measure_rows_height; use crate::history_cell; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ListKeymap; use crate::render::renderable::Renderable; #[cfg(test)] @@ -138,15 +140,35 @@ pub(crate) struct RequestUserInputOverlay { done: bool, pending_submission_draft: Option, confirm_unanswered: Option, + list_keymap: ListKeymap, } impl RequestUserInputOverlay { + #[cfg(test)] pub(crate) fn new( request: ToolRequestUserInputParams, app_event_tx: AppEventSender, has_input_focus: bool, enhanced_keys_supported: bool, disable_paste_burst: bool, + ) -> Self { + Self::new_with_keymap( + request, + app_event_tx, + has_input_focus, + enhanced_keys_supported, + disable_paste_burst, + crate::keymap::RuntimeKeymap::defaults().list, + ) + } + + pub(crate) fn new_with_keymap( + request: ToolRequestUserInputParams, + app_event_tx: AppEventSender, + has_input_focus: bool, + enhanced_keys_supported: bool, + disable_paste_burst: bool, + list_keymap: ListKeymap, ) -> Self { // Use the same composer widget, but disable popups/slash-commands and // image-path attachment so it behaves like a focused notes field. @@ -171,6 +193,7 @@ impl RequestUserInputOverlay { done: false, pending_submission_draft: None, confirm_unanswered: None, + list_keymap, }; overlay.reset_for_request(); overlay.ensure_focus_available(); @@ -1063,11 +1086,8 @@ impl BottomPaneView for RequestUserInputOverlay { code: KeyCode::Char('h'), modifiers: KeyModifiers::NONE, .. - } if self.has_options() && matches!(self.focus, Focus::Options) => { - self.move_question(/*next*/ false); - return; } - KeyEvent { + | KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::NONE, .. @@ -1075,19 +1095,30 @@ impl BottomPaneView for RequestUserInputOverlay { self.move_question(/*next*/ false); return; } + _ if self.has_options() + && matches!(self.focus, Focus::Options) + && self.list_keymap.move_left.is_pressed(key_event) => + { + self.move_question(/*next*/ false); + return; + } KeyEvent { code: KeyCode::Char('l'), modifiers: KeyModifiers::NONE, .. + } + | KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + .. } if self.has_options() && matches!(self.focus, Focus::Options) => { self.move_question(/*next*/ true); return; } - KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::NONE, - .. - } if self.has_options() && matches!(self.focus, Focus::Options) => { + _ if self.has_options() + && matches!(self.focus, Focus::Options) + && self.list_keymap.move_right.is_pressed(key_event) => + { self.move_question(/*next*/ true); return; } @@ -1886,6 +1917,30 @@ mod tests { assert_eq!(overlay.current_index(), 0); } + #[test] + fn horizontal_list_keys_move_between_questions_in_options() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Pick one"), + question_with_options("q2", "Pick two"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + assert_eq!(overlay.current_index(), 0); + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert_eq!(overlay.current_index(), 1); + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(overlay.current_index(), 0); + } + #[test] fn options_notes_focus_hides_question_navigation_tip() { let (tx, _rx) = test_sender(); diff --git a/codex-rs/tui/src/bottom_pane/scroll_state.rs b/codex-rs/tui/src/bottom_pane/scroll_state.rs index a9728d1a0d..a76f3821b1 100644 --- a/codex-rs/tui/src/bottom_pane/scroll_state.rs +++ b/codex-rs/tui/src/bottom_pane/scroll_state.rs @@ -4,6 +4,13 @@ /// - Optional selection (None when list is empty) /// - Wrap-around navigation on Up/Down /// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible +/// +/// Callers own the filtered row count and the visible window size. Every +/// mutation method takes those values instead of caching them here, so list +/// views can apply filters, pagination, or density changes without this helper +/// knowing about their data model. Passing a stale length after filtering would +/// leave selection pointing at the wrong row, so callers should clamp or move +/// through this type immediately after changing their visible row set. #[derive(Debug, Default, Clone, Copy)] pub(crate) struct ScrollState { pub selected_idx: Option, @@ -26,20 +33,15 @@ impl ScrollState { /// Clamp selection to be within the [0, len-1] range, or None when empty. pub fn clamp_selection(&mut self, len: usize) { - self.selected_idx = match len { - 0 => None, - _ => Some(self.selected_idx.unwrap_or(0).min(len - 1)), - }; - if len == 0 { - self.scroll_top = 0; + if self.clear_if_empty(len) { + return; } + self.selected_idx = Some(self.selected_idx.unwrap_or(0).min(len - 1)); } /// Move selection up by one, wrapping to the bottom when necessary. pub fn move_up_wrap(&mut self, len: usize) { - if len == 0 { - self.selected_idx = None; - self.scroll_top = 0; + if self.clear_if_empty(len) { return; } self.selected_idx = Some(match self.selected_idx { @@ -51,9 +53,7 @@ impl ScrollState { /// Move selection down by one, wrapping to the top when necessary. pub fn move_down_wrap(&mut self, len: usize) { - if len == 0 { - self.selected_idx = None; - self.scroll_top = 0; + if self.clear_if_empty(len) { return; } self.selected_idx = Some(match self.selected_idx { @@ -62,6 +62,63 @@ impl ScrollState { }); } + /// Move selection up by one visible page, clamping at the first row. + /// + /// Page movement intentionally does not wrap. It mirrors terminal list + /// behavior where repeated page-up/page-down converges at the nearest edge + /// while still keeping the selected row visible. + pub fn page_up_clamped(&mut self, len: usize, visible_rows: usize) { + if self.clear_if_empty(len) { + return; + } + let step = visible_rows.max(1); + let current = self.selected_idx.unwrap_or(0).min(len - 1); + self.selected_idx = Some(current.saturating_sub(step)); + self.ensure_visible(len, visible_rows); + } + + /// Move selection down by one visible page, clamping at the last row. + /// + /// Page movement intentionally does not wrap. It mirrors terminal list + /// behavior where repeated page-up/page-down converges at the nearest edge + /// while still keeping the selected row visible. + pub fn page_down_clamped(&mut self, len: usize, visible_rows: usize) { + if self.clear_if_empty(len) { + return; + } + let step = visible_rows.max(1); + let current = self.selected_idx.unwrap_or(0).min(len - 1); + self.selected_idx = Some(current.saturating_add(step).min(len - 1)); + self.ensure_visible(len, visible_rows); + } + + /// Jump selection to the first row. + pub fn jump_top(&mut self, len: usize, visible_rows: usize) { + if self.clear_if_empty(len) { + return; + } + self.selected_idx = Some(0); + self.ensure_visible(len, visible_rows); + } + + /// Jump selection to the last row. + pub fn jump_bottom(&mut self, len: usize, visible_rows: usize) { + if self.clear_if_empty(len) { + return; + } + self.selected_idx = Some(len - 1); + self.ensure_visible(len, visible_rows); + } + + fn clear_if_empty(&mut self, len: usize) -> bool { + if len != 0 { + return false; + } + self.selected_idx = None; + self.scroll_top = 0; + true + } + /// Adjust `scroll_top` so that the current `selected_idx` is visible within /// the window of `visible_rows`. pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) { @@ -112,4 +169,36 @@ mod tests { assert_eq!(s.selected_idx, Some(0)); assert_eq!(s.scroll_top, 0); } + + #[test] + fn page_and_jump_navigation_clamps() { + let mut s = ScrollState::new(); + let len = 10; + let vis = 4; + + s.clamp_selection(len); + s.page_down_clamped(len, vis); + assert_eq!(s.selected_idx, Some(4)); + assert_eq!(s.scroll_top, 1); + + s.page_down_clamped(len, vis); + assert_eq!(s.selected_idx, Some(8)); + assert_eq!(s.scroll_top, 5); + + s.page_down_clamped(len, vis); + assert_eq!(s.selected_idx, Some(9)); + assert_eq!(s.scroll_top, 6); + + s.page_up_clamped(len, vis); + assert_eq!(s.selected_idx, Some(5)); + assert_eq!(s.scroll_top, 5); + + s.jump_top(len, vis); + assert_eq!(s.selected_idx, Some(0)); + assert_eq!(s.scroll_top, 0); + + s.jump_bottom(len, vis); + assert_eq!(s.selected_idx, Some(9)); + assert_eq!(s.scroll_top, 6); + } } diff --git a/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs index 85c014f66b..5a5414ed49 100644 --- a/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs +++ b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs @@ -14,6 +14,10 @@ use ratatui::widgets::Widget; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::key_hint::is_plain_text_key_event; +use crate::keymap::ListKeymap; +use crate::keymap::primary_binding; use crate::render::Insets; use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; @@ -49,10 +53,15 @@ pub(crate) struct SkillsToggleView { footer_hint: Line<'static>, search_query: String, filtered_indices: Vec, + keymap: ListKeymap, } impl SkillsToggleView { - pub(crate) fn new(items: Vec, app_event_tx: AppEventSender) -> Self { + pub(crate) fn new( + items: Vec, + app_event_tx: AppEventSender, + keymap: ListKeymap, + ) -> Self { let mut header = ColumnRenderable::new(); header.push(Line::from("Enable/Disable Skills".bold())); header.push(Line::from( @@ -65,9 +74,10 @@ impl SkillsToggleView { complete: false, app_event_tx, header: Box::new(header), - footer_hint: skills_toggle_hint_line(), + footer_hint: skills_toggle_hint_line(&keymap), search_query: String::new(), filtered_indices: Vec::new(), + keymap, }; view.apply_filter(); view @@ -161,6 +171,30 @@ impl SkillsToggleView { self.state.ensure_visible(len, visible); } + fn page_up(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.page_up_clamped(len, visible); + } + + fn page_down(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.page_down_clamped(len, visible); + } + + fn jump_top(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.jump_top(len, visible); + } + + fn jump_bottom(&mut self) { + let len = self.visible_len(); + let visible = Self::max_visible_rows(len); + self.state.jump_bottom(len, visible); + } + fn toggle_selected(&mut self) { let Some(idx) = self.state.selected_idx else { return; @@ -200,34 +234,29 @@ impl SkillsToggleView { impl BottomPaneView for SkillsToggleView { fn handle_key_event(&mut self, key_event: KeyEvent) { + // Printable characters always feed search. Movement aliases such as + // plain j/k only apply through non-text events or modified bindings. + let allow_plain_char_navigation = !is_plain_text_key_event(key_event); + match key_event { - KeyEvent { - code: KeyCode::Up, .. + _ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => { + self.move_up() } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. + _ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => { + self.move_down() } - | KeyEvent { - code: KeyCode::Char('\u{0010}'), - modifiers: KeyModifiers::NONE, - .. - } /* ^P */ => self.move_up(), - KeyEvent { - code: KeyCode::Down, - .. + _ if allow_plain_char_navigation && self.keymap.page_up.is_pressed(key_event) => { + self.page_up() } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. + _ if allow_plain_char_navigation && self.keymap.page_down.is_pressed(key_event) => { + self.page_down() + } + _ if allow_plain_char_navigation && self.keymap.jump_top.is_pressed(key_event) => { + self.jump_top() + } + _ if allow_plain_char_navigation && self.keymap.jump_bottom.is_pressed(key_event) => { + self.jump_bottom() } - | KeyEvent { - code: KeyCode::Char('\u{000e}'), - modifiers: KeyModifiers::NONE, - .. - } /* ^N */ => self.move_down(), KeyEvent { code: KeyCode::Backspace, .. @@ -239,15 +268,9 @@ impl BottomPaneView for SkillsToggleView { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. - } - | KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. } => self.toggle_selected(), - KeyEvent { - code: KeyCode::Esc, .. - } => { + _ if self.keymap.accept.is_pressed(key_event) => self.toggle_selected(), + _ if self.keymap.cancel.is_pressed(key_event) => { self.on_ctrl_c(); } KeyEvent { @@ -364,16 +387,37 @@ impl Renderable for SkillsToggleView { } } -fn skills_toggle_hint_line() -> Line<'static> { - Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Char(' ')).into(), - " or ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to toggle; ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to close".into(), - ]) +fn skills_toggle_hint_line(keymap: &ListKeymap) -> Line<'static> { + let space = key_hint::plain(KeyCode::Char(' ')); + let accept = primary_binding(&keymap.accept).filter(|binding| *binding != space); + let cancel = primary_binding(&keymap.cancel); + + match (accept, cancel) { + (Some(accept), Some(cancel)) => Line::from(vec![ + "Press ".into(), + space.into(), + " or ".into(), + accept.into(), + " to toggle; ".into(), + cancel.into(), + " to close".into(), + ]), + (Some(accept), None) => Line::from(vec![ + "Press ".into(), + space.into(), + " or ".into(), + accept.into(), + " to toggle".into(), + ]), + (None, Some(cancel)) => Line::from(vec![ + "Press ".into(), + space.into(), + " to toggle; ".into(), + cancel.into(), + " to close".into(), + ]), + (None, None) => Line::from(vec!["Press ".into(), space.into(), " to toggle".into()]), + } } #[cfg(test)] @@ -429,7 +473,23 @@ mod tests { path: test_path_buf("/tmp/skills/changelog_writer.toml").abs(), }, ]; - let view = SkillsToggleView::new(items, tx); + let view = SkillsToggleView::new(items, tx, crate::keymap::RuntimeKeymap::defaults().list); assert_snapshot!("skills_toggle_basic", render_lines(&view, /*width*/ 72)); } + + #[test] + fn footer_hint_uses_list_keymap_accept_and_cancel() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults().list; + keymap.accept = vec![key_hint::ctrl(KeyCode::Char('t'))]; + keymap.cancel = vec![key_hint::ctrl(KeyCode::Char('x'))]; + let view = SkillsToggleView::new(Vec::new(), tx, keymap); + let rendered = render_lines(&view, /*width*/ 72); + + assert!(rendered.contains("ctrl + t")); + assert!(rendered.contains("ctrl + x")); + assert!(!rendered.contains("enter")); + assert!(!rendered.contains("esc")); + } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__zellij_empty_composer.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__zellij_empty_composer.snap deleted file mode 100644 index 15496a4539..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__zellij_empty_composer.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 4001 -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" ? for shortcuts 100% context left " 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 db86cf8a78..50eb41ed4a 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 @@ -18,4 +18,4 @@ expression: "render_lines(&view, 72)" [ ] 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 + Press space to toggle; ←/→ to move; enter to confirm and close; esc to diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap index 598fa418a1..be5cecebf1 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap @@ -18,4 +18,4 @@ expression: "render_lines(&view, 84)" [ ] context-remaining Percentage of context window remaining (omitted when unk… [ ! ] Action Required | my-project | Working | thread title - Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close 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 d96bc286a3..5ce13cd214 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -35,6 +35,7 @@ use crate::bottom_pane::multi_select_picker::MultiSelectItem; use crate::bottom_pane::multi_select_picker::MultiSelectPicker; use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewData; use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewItem; +use crate::keymap::ListKeymap; use crate::render::renderable::Renderable; const STATUS_LINE_USE_THEME_COLORS_ITEM_ID: &str = "status-line-use-theme-colors"; @@ -246,6 +247,7 @@ impl StatusLineSetupView { use_theme_colors: bool, preview_data: StatusSurfacePreviewData, app_event_tx: AppEventSender, + list_keymap: ListKeymap, ) -> Self { let mut used_ids = HashSet::new(); let mut items = vec![MultiSelectItem { @@ -284,10 +286,7 @@ impl StatusLineSetupView { Some("Select which items to display in the status line.".to_string()), app_event_tx, ) - .instructions(vec![ - "Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel." - .into(), - ]) + .list_keymap(list_keymap) .items(items) .enable_ordering() .on_preview(move |items| { @@ -621,6 +620,7 @@ mod tests { ), ]), AppEventSender::new(tx_raw), + crate::keymap::RuntimeKeymap::defaults().list, ); assert_snapshot!(render_lines(&view, /*width*/ 72)); diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 0638412110..96f6cf8cbb 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -24,6 +24,7 @@ use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::style::Color; use ratatui::style::Style; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; @@ -1893,7 +1894,6 @@ impl TextArea { buf: &mut Buffer, state: &mut TextAreaState, mask_char: char, - base_style: Style, ) { let lines = self.wrapped_lines(area.width); let scroll = self.effective_scroll(area.height, &lines, state.scroll); @@ -1901,25 +1901,7 @@ impl TextArea { let start = scroll as usize; let end = (scroll + area.height).min(lines.len() as u16) as usize; - self.render_lines_masked(area, buf, &lines, start..end, mask_char, base_style); - } - - /// Render the textarea with an explicit `base_style` applied to every cell, - /// used by the Zellij code path to override inherited terminal styles. - pub(crate) fn render_ref_styled( - &self, - area: Rect, - buf: &mut Buffer, - state: &mut TextAreaState, - base_style: Style, - ) { - let lines = self.wrapped_lines(area.width); - let scroll = self.effective_scroll(area.height, &lines, state.scroll); - state.scroll = scroll; - - let start = scroll as usize; - let end = (scroll + area.height).min(lines.len() as u16) as usize; - self.render_lines(area, buf, &lines, start..end, base_style, &[]); + self.render_lines_masked(area, buf, &lines, start..end, mask_char); } /// Render the textarea with `base_style` plus additional render-only highlight ranges. @@ -1970,7 +1952,7 @@ impl TextArea { } let styled = &self.text[overlap_start..overlap_end]; let x_off = self.text[line_range.start..overlap_start].width() as u16; - let style = base_style.fg(ratatui::style::Color::Cyan); + let style = base_style.fg(Color::Cyan); buf.set_string(area.x + x_off, y, styled, style); } @@ -1996,18 +1978,16 @@ impl TextArea { lines: &[Range], range: std::ops::Range, mask_char: char, - base_style: Style, ) { for (row, idx) in range.enumerate() { let r = &lines[idx]; let y = area.y + row as u16; let line_range = r.start..r.end - 1; - buf.set_style(Rect::new(area.x, y, area.width, 1), base_style); let masked = self.text[line_range.clone()] .chars() .map(|_| mask_char) .collect::(); - buf.set_string(area.x, y, &masked, base_style); + buf.set_string(area.x, y, &masked, Style::default()); } } } diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index ae3c56ca3b..f37991bc00 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -26,6 +26,7 @@ use crate::bottom_pane::multi_select_picker::MultiSelectItem; use crate::bottom_pane::multi_select_picker::MultiSelectPicker; use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewData; use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewItem; +use crate::keymap::ListKeymap; use crate::render::renderable::Renderable; /// Available items that can be displayed in the terminal title. @@ -244,6 +245,7 @@ impl TerminalTitleSetupView { title_items: Option<&[String]>, preview_data: StatusSurfacePreviewData, app_event_tx: AppEventSender, + list_keymap: ListKeymap, ) -> Self { let selected_items = title_items .into_iter() @@ -271,10 +273,7 @@ impl TerminalTitleSetupView { Some("Select which items to display in the terminal title.".to_string()), app_event_tx, ) - .instructions(vec![ - "Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel." - .into(), - ]) + .list_keymap(list_keymap) .items(items) .enable_ordering() .on_preview(move |items| { @@ -387,8 +386,12 @@ mod tests { "run-state".to_string(), "thread-title".to_string(), ]; - let view = - TerminalTitleSetupView::new(Some(&selected), StatusSurfacePreviewData::default(), tx); + let view = TerminalTitleSetupView::new( + Some(&selected), + StatusSurfacePreviewData::default(), + tx, + crate::keymap::RuntimeKeymap::defaults().list, + ); assert_snapshot!( "terminal_title_setup_basic", render_lines(&view, /*width*/ 84) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9efc995313..6723577754 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -32,8 +32,6 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; -use std::collections::VecDeque; -use std::ops::Deref; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -113,7 +111,6 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillMetadata as ProtocolSkillMetadata; use codex_app_server_protocol::SkillsListResponse; -use codex_app_server_protocol::TextElement as AppServerTextElement; use codex_app_server_protocol::ThreadGoal as AppThreadGoal; use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus; use codex_app_server_protocol::ThreadItem; @@ -158,8 +155,6 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::models::MessagePhase; -use codex_protocol::models::local_image_label_text; -use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus; use codex_protocol::request_permissions::RequestPermissionsEvent; @@ -321,9 +316,18 @@ use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; +mod command_lifecycle; mod connectors; use self::connectors::ConnectorsCacheState; use self::connectors::ConnectorsState; +mod exec_state; +use self::exec_state::RunningCommand; +use self::exec_state::UnifiedExecProcessSummary; +use self::exec_state::UnifiedExecWaitState; +use self::exec_state::UnifiedExecWaitStreak; +use self::exec_state::command_execution_command_and_parsed; +use self::exec_state::is_standard_tool_call; +use self::exec_state::is_unified_exec_source; mod goal_status; use self::goal_status::GoalStatusState; #[cfg(test)] @@ -334,6 +338,9 @@ mod ide_context; use self::ide_context::IdeContextState; mod input_queue; use self::input_queue::InputQueueState; +mod input_flow; +mod input_restore; +mod input_submission; mod interrupts; use self::interrupts::InterruptManager; mod keymap_picker; @@ -342,6 +349,7 @@ use self::mcp_startup::McpStartupStatus; mod pets; mod session_header; use self::session_header::SessionHeader; +mod hook_lifecycle; mod hooks; mod skills; mod slash_dispatch; @@ -349,11 +357,24 @@ use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; mod plugins; +use self::plugins::PluginInstallAuthFlowState; +use self::plugins::PluginListFetchState; use self::plugins::PluginsCacheState; mod plan_implementation; use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; mod protocol; +mod protocol_requests; +mod rate_limits; +use self::rate_limits::NUDGE_MODEL_SLUG; +use self::rate_limits::RATE_LIMIT_SWITCH_PROMPT_THRESHOLD; +use self::rate_limits::RateLimitErrorKind; +use self::rate_limits::RateLimitSwitchPromptState; +use self::rate_limits::RateLimitWarningState; +use self::rate_limits::app_server_rate_limit_error_kind; +pub(crate) use self::rate_limits::get_limits_duration; +use self::rate_limits::is_app_server_cyber_policy_error; mod realtime; +mod replay; use self::realtime::RealtimeConversationUiState; mod reasoning_shortcuts; mod review; @@ -365,14 +386,37 @@ use self::status_state::StatusIndicatorState; use self::status_state::StatusState; use self::status_state::TerminalTitleStatusKind; mod status_surfaces; +mod streaming; use self::status_surfaces::CachedProjectRootName; +mod tool_lifecycle; +mod tool_requests; mod transcript; use self::transcript::TranscriptState; mod turn_lifecycle; +mod turn_runtime; use self::turn_lifecycle::TurnLifecycleState; mod user_messages; +use self::user_messages::PendingSteer; use self::user_messages::PendingSteerCompareKey; +use self::user_messages::QueueDrain; +use self::user_messages::QueuedUserMessage; +use self::user_messages::ShellEscapePolicy; +use self::user_messages::ThreadComposerState; +pub(crate) use self::user_messages::ThreadInputState; +pub(crate) use self::user_messages::UserMessage; use self::user_messages::UserMessageDisplay; +#[cfg(test)] +use self::user_messages::UserMessageHistoryOverride; +use self::user_messages::UserMessageHistoryRecord; +use self::user_messages::app_server_text_elements; +pub(crate) use self::user_messages::create_initial_user_message; +use self::user_messages::merge_user_messages; +use self::user_messages::merge_user_messages_with_history_record; +#[cfg(test)] +use self::user_messages::remap_placeholders_for_message; +use self::user_messages::user_message_display_for_history; +use self::user_messages::user_message_for_restore; +use self::user_messages::user_message_preview_text; mod warnings; use self::warnings::WarningDisplayState; pub(crate) use crate::branch_summary::StatusLineGitSummary; @@ -401,176 +445,8 @@ const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it l const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; const DEFAULT_STATUS_LINE_ITEMS: [&str; 2] = ["model-with-reasoning", "current-dir"]; -// Track information about an in-flight exec command. -struct RunningCommand { - command: Vec, - parsed_cmd: Vec, - source: ExecCommandSource, -} - -struct UnifiedExecProcessSummary { - key: String, - call_id: String, - command_display: String, - recent_chunks: Vec, -} - -struct UnifiedExecWaitState { - command_display: String, -} - -impl UnifiedExecWaitState { - fn new(command_display: String) -> Self { - Self { command_display } - } - - fn is_duplicate(&self, command_display: &str) -> bool { - self.command_display == command_display - } -} - -#[derive(Clone, Debug)] -struct UnifiedExecWaitStreak { - process_id: String, - command_display: Option, -} - -impl UnifiedExecWaitStreak { - fn new(process_id: String, command_display: Option) -> Self { - Self { - process_id, - command_display: command_display.filter(|display| !display.is_empty()), - } - } - - fn update_command_display(&mut self, command_display: Option) { - if self.command_display.is_some() { - return; - } - self.command_display = command_display.filter(|display| !display.is_empty()); - } -} - -fn is_unified_exec_source(source: ExecCommandSource) -> bool { - matches!( - source, - ExecCommandSource::UnifiedExecStartup | ExecCommandSource::UnifiedExecInteraction - ) -} - -fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool { - !parsed_cmd.is_empty() - && parsed_cmd - .iter() - .all(|parsed| !matches!(parsed, ParsedCommand::Unknown { .. })) -} - -fn command_execution_command_and_parsed( - command: &str, - command_actions: &[codex_app_server_protocol::CommandAction], -) -> (Vec, Vec) { - ( - split_command_string(command), - command_actions - .iter() - .cloned() - .map(codex_app_server_protocol::CommandAction::into_core) - .collect(), - ) -} - -const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; -const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; -const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; const MAX_AGENT_COPY_HISTORY: usize = 32; -#[derive(Default)] -struct RateLimitWarningState { - secondary_index: usize, - primary_index: usize, -} - -impl RateLimitWarningState { - fn take_warnings( - &mut self, - secondary_used_percent: Option, - secondary_window_minutes: Option, - primary_used_percent: Option, - primary_window_minutes: Option, - ) -> Vec { - let reached_secondary_cap = - matches!(secondary_used_percent, Some(percent) if percent == 100.0); - let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0); - if reached_secondary_cap || reached_primary_cap { - return Vec::new(); - } - - let mut warnings = Vec::new(); - - if let Some(secondary_used_percent) = secondary_used_percent { - let mut highest_secondary: Option = None; - while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() - && secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index] - { - highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]); - self.secondary_index += 1; - } - if let Some(threshold) = highest_secondary { - let limit_label = secondary_window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()); - let remaining_percent = 100.0 - threshold; - warnings.push(format!( - "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." - )); - } - } - - if let Some(primary_used_percent) = primary_used_percent { - let mut highest_primary: Option = None; - while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() - && primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index] - { - highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]); - self.primary_index += 1; - } - if let Some(threshold) = highest_primary { - let limit_label = primary_window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()); - let remaining_percent = 100.0 - threshold; - warnings.push(format!( - "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." - )); - } - } - - warnings - } -} - -pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { - const MINUTES_PER_HOUR: i64 = 60; - const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; - const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; - const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; - const ROUNDING_BIAS_MINUTES: i64 = 3; - - let windows_minutes = windows_minutes.max(0); - - if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { - let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); - let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR); - format!("{hours}h") - } else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) { - "weekly".to_string() - } else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) { - "monthly".to_string() - } else { - "annual".to_string() - } -} - /// Common initialization parameters shared by all `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { pub(crate) config: Config, @@ -600,48 +476,6 @@ pub(crate) struct ChatWidgetInit { pub(crate) session_telemetry: SessionTelemetry, } -#[derive(Default)] -enum RateLimitSwitchPromptState { - #[default] - Idle, - Pending, - Shown, -} - -#[derive(Debug, Clone, Default)] -struct PluginListFetchState { - cache_cwd: Option, - in_flight_cwd: Option, -} - -#[derive(Debug, Clone)] -struct PluginInstallAuthFlowState { - plugin_display_name: String, - next_app_index: usize, -} - -#[derive(Debug)] -enum RateLimitErrorKind { - ServerOverloaded, - UsageLimit, - Generic, -} - -fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option { - match info { - AppServerCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded), - AppServerCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit), - AppServerCodexErrorInfo::ResponseTooManyFailedAttempts { - http_status_code: Some(429), - } => Some(RateLimitErrorKind::Generic), - _ => None, - } -} - -fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool { - matches!(info, AppServerCodexErrorInfo::CyberPolicy) -} - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExternalEditorState { #[default] @@ -890,148 +724,6 @@ pub(crate) struct ActiveCellTranscriptKey { pub(crate) animation_tick: Option, } -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct UserMessage { - text: String, - local_images: Vec, - /// Remote image attachments represented as URLs (for example data URLs) - /// provided by app-server clients. - /// - /// Unlike `local_images`, these are not created by TUI image attach/paste - /// flows. The TUI can restore and remove them while editing/backtracking. - remote_image_urls: Vec, - text_elements: Vec, - mention_bindings: Vec, -} - -#[derive(Clone, Debug, PartialEq)] -enum UserMessageHistoryRecord { - UserMessageText, - Override(UserMessageHistoryOverride), -} - -#[derive(Clone, Debug, PartialEq)] -struct UserMessageHistoryOverride { - text: String, - text_elements: Vec, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShellEscapePolicy { - Allow, - Disallow, -} - -#[derive(Debug, Clone, PartialEq)] -struct QueuedUserMessage { - user_message: UserMessage, - action: QueuedInputAction, -} - -impl QueuedUserMessage { - fn new(user_message: UserMessage, action: QueuedInputAction) -> Self { - Self { - user_message, - action, - } - } - - fn into_user_message(self) -> UserMessage { - self.user_message - } -} - -impl From for QueuedUserMessage { - fn from(user_message: UserMessage) -> Self { - Self::new(user_message, QueuedInputAction::Plain) - } -} - -impl Deref for QueuedUserMessage { - type Target = UserMessage; - - fn deref(&self) -> &Self::Target { - &self.user_message - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum QueueDrain { - Continue, - Stop, -} - -#[derive(Debug, Clone, PartialEq, Default)] -struct ThreadComposerState { - text: String, - local_images: Vec, - remote_image_urls: Vec, - text_elements: Vec, - mention_bindings: Vec, - pending_pastes: Vec<(String, String)>, -} - -impl ThreadComposerState { - fn has_content(&self) -> bool { - !self.text.is_empty() - || !self.local_images.is_empty() - || !self.remote_image_urls.is_empty() - || !self.text_elements.is_empty() - || !self.mention_bindings.is_empty() - || !self.pending_pastes.is_empty() - } -} - -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct ThreadInputState { - composer: Option, - pending_steers: VecDeque, - pending_steer_history_records: VecDeque, - pending_steer_compare_keys: VecDeque, - rejected_steers_queue: VecDeque, - rejected_steer_history_records: VecDeque, - queued_user_messages: VecDeque, - queued_user_message_history_records: VecDeque, - user_turn_pending_start: bool, - current_collaboration_mode: CollaborationMode, - active_collaboration_mask: Option, - task_running: bool, - agent_turn_running: bool, -} - -impl From for UserMessage { - fn from(text: String) -> Self { - Self { - text, - local_images: Vec::new(), - remote_image_urls: Vec::new(), - // Plain text conversion has no UI element ranges. - text_elements: Vec::new(), - mention_bindings: Vec::new(), - } - } -} - -impl From<&str> for UserMessage { - fn from(text: &str) -> Self { - Self { - text: text.to_string(), - local_images: Vec::new(), - remote_image_urls: Vec::new(), - // Plain text conversion has no UI element ranges. - text_elements: Vec::new(), - mention_bindings: Vec::new(), - } - } -} - -#[derive(Debug)] -struct PendingSteer { - user_message: UserMessage, - history_record: UserMessageHistoryRecord, - compare_key: PendingSteerCompareKey, -} - #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) enum InterruptedTurnNoticeMode { #[default] @@ -1039,329 +731,6 @@ pub(crate) enum InterruptedTurnNoticeMode { Suppress, } -pub(crate) fn create_initial_user_message( - text: Option, - local_image_paths: Vec, - text_elements: Vec, -) -> Option { - let text = text.unwrap_or_default(); - if text.is_empty() && local_image_paths.is_empty() { - None - } else { - let local_images = local_image_paths - .into_iter() - .enumerate() - .map(|(idx, path)| LocalImageAttachment { - placeholder: local_image_label_text(idx + 1), - path, - }) - .collect(); - Some(UserMessage { - text, - local_images, - remote_image_urls: Vec::new(), - text_elements, - mention_bindings: Vec::new(), - }) - } -} - -fn append_text_with_rebased_elements( - target_text: &mut String, - target_text_elements: &mut Vec, - text: &str, - text_elements: impl IntoIterator, -) { - let offset = target_text.len(); - target_text.push_str(text); - target_text_elements.extend(text_elements.into_iter().map(|mut element| { - element.byte_range.start += offset; - element.byte_range.end += offset; - element - })); -} - -fn app_server_text_elements(elements: &[TextElement]) -> Vec { - elements.iter().cloned().map(Into::into).collect() -} - -fn build_placeholder_mapping( - local_images: Vec, - next_label: &mut usize, -) -> (HashMap, Vec) { - let mut mapping: HashMap = HashMap::new(); - let mut remapped_images = Vec::new(); - for attachment in local_images { - let new_placeholder = local_image_label_text(*next_label); - *next_label += 1; - mapping.insert(attachment.placeholder.clone(), new_placeholder.clone()); - remapped_images.push(LocalImageAttachment { - placeholder: new_placeholder, - path: attachment.path, - }); - } - (mapping, remapped_images) -} - -fn remap_placeholders_in_text( - text: String, - text_elements: Vec, - mapping: &HashMap, -) -> (String, Vec) { - if mapping.is_empty() { - return (text, text_elements); - } - - let mut elements = text_elements; - elements.sort_by_key(|elem| elem.byte_range.start); - - let mut cursor = 0usize; - let mut rebuilt = String::new(); - let mut rebuilt_elements = Vec::new(); - for mut elem in elements { - let start = elem.byte_range.start.min(text.len()); - let end = elem.byte_range.end.min(text.len()); - if let Some(segment) = text.get(cursor..start) { - rebuilt.push_str(segment); - } - - let original = text.get(start..end).unwrap_or(""); - let placeholder = elem.placeholder(&text); - let replacement = placeholder - .and_then(|ph| mapping.get(ph)) - .map(String::as_str) - .unwrap_or(original); - - let elem_start = rebuilt.len(); - rebuilt.push_str(replacement); - let elem_end = rebuilt.len(); - - if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { - elem.set_placeholder(Some(remapped.clone())); - } - elem.byte_range = (elem_start..elem_end).into(); - rebuilt_elements.push(elem); - cursor = end; - } - if let Some(segment) = text.get(cursor..) { - rebuilt.push_str(segment); - } - - (rebuilt, rebuilt_elements) -} - -// When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering -// its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so -// the combined local_image_paths order matches the labels, even if placeholders were moved -// in the text (e.g., [Image #2] appearing before [Image #1]). Apply the same remapping to -// history overrides so restored drafts and rendered transcript entries agree. -fn remap_placeholders_for_message_and_history_record( - message: UserMessage, - history_record: UserMessageHistoryRecord, - next_label: &mut usize, -) -> (UserMessage, UserMessageHistoryRecord) { - let UserMessage { - text, - text_elements, - local_images, - remote_image_urls, - mention_bindings, - } = message; - let (mapping, remapped_images) = build_placeholder_mapping(local_images, next_label); - let (text, text_elements) = remap_placeholders_in_text(text, text_elements, &mapping); - let history_record = match history_record { - UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { - let (text, text_elements) = - remap_placeholders_in_text(history.text, history.text_elements, &mapping); - UserMessageHistoryRecord::Override(UserMessageHistoryOverride { - text, - text_elements, - }) - } - record => record, - }; - - ( - UserMessage { - text, - local_images: remapped_images, - remote_image_urls, - text_elements, - mention_bindings, - }, - history_record, - ) -} - -#[cfg(test)] -fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) -> UserMessage { - remap_placeholders_for_message_and_history_record( - message, - UserMessageHistoryRecord::UserMessageText, - next_label, - ) - .0 -} - -fn remap_user_messages_with_history_records( - messages: Vec<(UserMessage, UserMessageHistoryRecord)>, -) -> Vec<(UserMessage, UserMessageHistoryRecord)> { - let total_remote_images = messages - .iter() - .map(|(message, _)| message.remote_image_urls.len()) - .sum::(); - let mut next_image_label = total_remote_images + 1; - messages - .into_iter() - .map(|(message, history_record)| { - remap_placeholders_for_message_and_history_record( - message, - history_record, - &mut next_image_label, - ) - }) - .collect() -} - -fn merge_user_messages(messages: Vec) -> UserMessage { - let messages = remap_user_messages_with_history_records( - messages - .into_iter() - .map(|message| (message, UserMessageHistoryRecord::UserMessageText)) - .collect(), - ); - merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message)) -} - -fn merge_remapped_user_messages(messages: impl IntoIterator) -> UserMessage { - let mut combined = UserMessage { - text: String::new(), - text_elements: Vec::new(), - local_images: Vec::new(), - remote_image_urls: Vec::new(), - mention_bindings: Vec::new(), - }; - - for (idx, message) in messages.into_iter().enumerate() { - if idx > 0 { - combined.text.push('\n'); - } - let UserMessage { - text, - text_elements, - local_images, - remote_image_urls, - mention_bindings, - } = message; - append_text_with_rebased_elements( - &mut combined.text, - &mut combined.text_elements, - &text, - text_elements, - ); - combined.local_images.extend(local_images); - combined.remote_image_urls.extend(remote_image_urls); - combined.mention_bindings.extend(mention_bindings); - } - - combined -} - -fn user_message_for_restore( - message: UserMessage, - history_record: &UserMessageHistoryRecord, -) -> UserMessage { - match history_record { - UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => UserMessage { - text: history.text.clone(), - text_elements: history.text_elements.clone(), - ..message - }, - UserMessageHistoryRecord::Override(_) | UserMessageHistoryRecord::UserMessageText => { - message - } - } -} - -fn user_message_preview_text( - message: &UserMessage, - history_record: Option<&UserMessageHistoryRecord>, -) -> String { - match history_record { - Some(UserMessageHistoryRecord::Override(history)) if !history.text.is_empty() => { - history.text.clone() - } - Some(UserMessageHistoryRecord::Override(_)) - | Some(UserMessageHistoryRecord::UserMessageText) - | None => message.text.clone(), - } -} - -fn user_message_display_for_history( - message: UserMessage, - history_record: &UserMessageHistoryRecord, -) -> UserMessageDisplay { - let message = user_message_for_restore(message, history_record); - ChatWidget::user_message_display_from_parts( - message.text, - message.text_elements, - message - .local_images - .into_iter() - .map(|image| image.path) - .collect(), - message.remote_image_urls, - ) -} - -fn merge_user_messages_with_history_record( - messages: Vec<(UserMessage, UserMessageHistoryRecord)>, -) -> (UserMessage, UserMessageHistoryRecord) { - let messages = remap_user_messages_with_history_records(messages); - let history_record = if messages - .iter() - .all(|(_, record)| *record == UserMessageHistoryRecord::UserMessageText) - { - UserMessageHistoryRecord::UserMessageText - } else { - let mut history_text = String::new(); - let mut history_text_elements = Vec::new(); - let mut history_segment_count = 0usize; - let mut append_history_segment = |text: &str, text_elements: Vec| { - if history_segment_count > 0 { - history_text.push('\n'); - } - append_text_with_rebased_elements( - &mut history_text, - &mut history_text_elements, - text, - text_elements, - ); - history_segment_count += 1; - }; - for (message, record) in &messages { - match record { - UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { - append_history_segment(&history.text, history.text_elements.clone()); - } - UserMessageHistoryRecord::Override(_) if message.text.is_empty() => {} - UserMessageHistoryRecord::Override(_) - | UserMessageHistoryRecord::UserMessageText => { - append_history_segment(&message.text, message.text_elements.clone()); - } - } - } - UserMessageHistoryRecord::Override(UserMessageHistoryOverride { - text: history_text, - text_elements: history_text_elements, - }) - }; - ( - merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message)), - history_record, - ) -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ReplayKind { ResumeInitialMessages, @@ -1527,112 +896,6 @@ impl ChatWidget { self.realtime_conversation_enabled() } - /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. - /// - /// The bottom pane only has one running flag, but this module treats it as a derived state of - /// both the agent turn lifecycle and MCP startup lifecycle. - fn update_task_running_state(&mut self) { - self.bottom_pane.set_task_running( - self.turn_lifecycle.agent_turn_running || self.mcp_startup_status.is_some(), - ); - self.refresh_plan_mode_nudge(); - self.refresh_status_surfaces(); - } - - fn restore_reasoning_status_header(&mut self) { - if let Some(header) = extract_first_bold(&self.reasoning_buffer) { - self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; - self.set_status_header(header); - } else if self.bottom_pane.is_task_running() { - self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; - self.set_status_header(String::from("Working")); - } - } - - fn flush_unified_exec_wait_streak(&mut self) { - let Some(wait) = self.unified_exec_wait_streak.take() else { - return; - }; - self.transcript.needs_final_message_separator = true; - let cell = history_cell::new_unified_exec_interaction(wait.command_display, String::new()); - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(cell))); - self.restore_reasoning_status_header(); - } - - fn flush_answer_stream_with_separator(&mut self) { - let had_stream_controller = self.stream_controller.is_some(); - if let Some(mut controller) = self.stream_controller.take() { - let scrollback_reflow = if controller.has_live_tail() { - crate::app_event::ConsolidationScrollbackReflow::Required - } else { - crate::app_event::ConsolidationScrollbackReflow::IfResizeReflowRan - }; - self.clear_active_stream_tail(); - let (cell, source) = controller.finalize(); - let deferred_history_cell = - if scrollback_reflow == crate::app_event::ConsolidationScrollbackReflow::Required { - cell - } else { - if let Some(cell) = cell { - self.add_boxed_history(cell); - } - None - }; - // Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell - // that can re-render from source on resize. - if let Some(source) = source { - let source = parse_assistant_markdown(&source).visible_markdown; - self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { - source, - cwd: self.config.cwd.to_path_buf(), - scrollback_reflow, - deferred_history_cell, - }); - } - } - self.adaptive_chunking.reset(); - if had_stream_controller && self.stream_controllers_idle() { - self.app_event_tx.send(AppEvent::StopCommitAnimation); - } - } - - fn stream_controllers_idle(&self) -> bool { - self.stream_controller - .as_ref() - .map(|controller| controller.queued_lines() == 0) - .unwrap_or(true) - && self - .plan_stream_controller - .as_ref() - .map(|controller| controller.queued_lines() == 0) - .unwrap_or(true) - } - - /// Restore the status indicator only after commentary completion is pending, - /// the turn is still running, and all stream queues have drained. - /// - /// This gate prevents flicker while normal output is still actively - /// streaming, but still restores a visible "working" affordance when a - /// commentary block ends before the turn itself has completed. - fn maybe_restore_status_indicator_after_stream_idle(&mut self) { - if !self.status_state.pending_status_indicator_restore - || !self.bottom_pane.is_task_running() - || !self.stream_controllers_idle() - { - return; - } - - self.bottom_pane.ensure_status_indicator(); - self.set_status( - self.status_state.current_status.header.clone(), - self.status_state.current_status.details.clone(), - StatusDetailsCapitalization::Preserve, - self.status_state.current_status.details_max_lines, - ); - self.status_state.pending_status_indicator_restore = false; - } - /// Update the status indicator header and details. /// /// Passing `None` clears any existing details. @@ -1814,32 +1077,6 @@ impl ChatWidget { 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); - } - } - - fn apply_runtime_metrics_delta(&mut self, delta: RuntimeMetricsSummary) { - let should_log_timing = has_websocket_timing_metrics(delta); - self.turn_runtime_metrics.merge(delta); - if should_log_timing { - self.log_websocket_timing_totals(delta); - } - } - - fn log_websocket_timing_totals(&mut self, delta: RuntimeMetricsSummary) { - if let Some(label) = history_cell::runtime_metrics_label(delta.responses_api_summary()) { - self.add_plain_history_lines(vec![ - vec!["• ".dim(), format!("WebSocket timing: {label}").dark_gray()].into(), - ]); - } - } - - fn refresh_runtime_metrics(&mut self) { - self.collect_runtime_metrics_delta(); - } - fn restore_retry_status_header_if_present(&mut self) { if let Some(header) = self.status_state.take_retry_status_header() { self.set_status_header(header); @@ -1979,16 +1216,6 @@ impl ChatWidget { } } - pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) { - self.suppress_initial_user_message_submit = suppressed; - } - - pub(crate) fn submit_initial_user_message_if_pending(&mut self) { - if let Some(user_message) = self.initial_user_message.take() { - self.submit_user_message(user_message); - } - } - pub(crate) fn handle_thread_session(&mut self, session: ThreadSessionState) { self.instruction_source_paths = session.instruction_source_paths.clone(); let fork_parent_title = session.fork_parent_title.clone(); @@ -2090,7 +1317,11 @@ impl ChatWidget { } pub(crate) fn open_app_link_view(&mut self, params: crate::bottom_pane::AppLinkViewParams) { - let view = crate::bottom_pane::AppLinkView::new(params, self.app_event_tx.clone()); + let view = crate::bottom_pane::AppLinkView::new_with_keymap( + params, + self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), + ); self.bottom_pane.show_view(Box::new(view)); self.request_redraw(); } @@ -2119,469 +1350,6 @@ impl ChatWidget { self.request_redraw(); } - fn finalize_completed_assistant_message(&mut self, message: Option<&str>) { - // If we have a stream_controller, the finalized message payload is redundant because the - // visible content has already been accumulated through deltas. - if self.stream_controller.is_none() - && let Some(message) = message - && !message.is_empty() - { - self.handle_streaming_delta(message.to_string()); - } - self.flush_answer_stream_with_separator(); - self.handle_stream_finished(); - self.request_redraw(); - } - - fn on_agent_message_delta(&mut self, delta: String) { - self.handle_streaming_delta(delta); - } - - fn on_plan_delta(&mut self, delta: String) { - if self.active_mode_kind() != ModeKind::Plan { - return; - } - if !self.transcript.plan_item_active { - self.transcript.plan_item_active = true; - self.transcript.plan_delta_buffer.clear(); - } - self.transcript.plan_delta_buffer.push_str(&delta); - if self.plan_stream_controller.is_none() { - // Before starting a plan stream, flush any active exec cell group. - self.flush_unified_exec_wait_streak(); - self.flush_active_cell(); - self.plan_stream_controller = Some(PlanStreamController::new( - self.current_stream_width(/*reserved_cols*/ 4), - &self.config.cwd, - self.history_render_mode(), - )); - } - if let Some(controller) = self.plan_stream_controller.as_mut() - && controller.push(&delta) - { - self.app_event_tx.send(AppEvent::StartCommitAnimation); - self.run_catch_up_commit_tick(); - } - self.sync_active_stream_tail(); - self.request_redraw(); - } - - fn on_plan_item_completed(&mut self, text: String) { - let streamed_plan = self.transcript.plan_delta_buffer.trim().to_string(); - let plan_text = if text.trim().is_empty() { - streamed_plan - } else { - text - }; - if !plan_text.trim().is_empty() { - self.record_agent_markdown(&plan_text); - self.transcript.latest_proposed_plan_markdown = Some(plan_text.clone()); - } - // Plan commit ticks can hide the status row; remember whether we streamed plan output so - // completion can restore it once stream queues are idle. - let should_restore_after_stream = self.plan_stream_controller.is_some(); - self.transcript.plan_delta_buffer.clear(); - self.transcript.plan_item_active = false; - self.transcript.saw_plan_item_this_turn = true; - let (finalized_streamed_cell, consolidated_plan_source) = - if let Some(mut controller) = self.plan_stream_controller.take() { - let had_live_tail = controller.has_live_tail(); - self.clear_active_stream_tail(); - let (cell, source) = controller.finalize(); - if had_live_tail { - (None, source) - } else { - (cell, source) - } - } else { - (None, None) - }; - if let Some(cell) = finalized_streamed_cell { - self.add_boxed_history(cell); - // TODO: Replace streamed output with the final plan item text if plan streaming is - // removed or if we need to reconcile mismatches between streamed and final content. - if let Some(source) = consolidated_plan_source { - self.app_event_tx - .send(AppEvent::ConsolidateProposedPlan(source)); - } - } else if !plan_text.is_empty() { - self.add_to_history(history_cell::new_proposed_plan(plan_text, &self.config.cwd)); - } else if let Some(source) = consolidated_plan_source { - self.app_event_tx - .send(AppEvent::ConsolidateProposedPlan(source)); - } - if should_restore_after_stream { - self.status_state.pending_status_indicator_restore = true; - self.maybe_restore_status_indicator_after_stream_idle(); - } - } - - fn on_agent_reasoning_delta(&mut self, delta: String) { - // For reasoning deltas, do not stream to history. Accumulate the - // current reasoning block and extract the first bold element - // (between **/**) as the chunk header. Show this header as status. - self.reasoning_buffer.push_str(&delta); - - if self.unified_exec_wait_streak.is_some() { - // Unified exec waiting should take precedence over reasoning-derived status headers. - self.request_redraw(); - return; - } - - if let Some(header) = extract_first_bold(&self.reasoning_buffer) { - // Update the shimmer header to the extracted reasoning chunk header. - self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; - self.set_status_header(header); - } else { - // Fallback while we don't yet have a bold header: leave existing header as-is. - } - self.request_redraw(); - } - - fn on_agent_reasoning_final(&mut self) { - // At the end of a reasoning block, record transcript-only content. - self.full_reasoning_buffer.push_str(&self.reasoning_buffer); - if !self.full_reasoning_buffer.is_empty() { - let cell = history_cell::new_reasoning_summary_block( - self.full_reasoning_buffer.clone(), - &self.config.cwd, - ); - self.add_boxed_history(cell); - } - self.reasoning_buffer.clear(); - self.full_reasoning_buffer.clear(); - self.request_redraw(); - } - - fn on_reasoning_section_break(&mut self) { - // Start a new reasoning block for header extraction and accumulate transcript. - self.full_reasoning_buffer.push_str(&self.reasoning_buffer); - self.full_reasoning_buffer.push_str("\n\n"); - self.reasoning_buffer.clear(); - } - - // Raw reasoning uses the same flow as summarized reasoning - - fn on_task_started(&mut self) { - self.input_queue.user_turn_pending_start = false; - self.turn_lifecycle.start(Instant::now()); - self.transcript.reset_turn_flags(); - self.adaptive_chunking.reset(); - self.plan_stream_controller = None; - self.turn_runtime_metrics = RuntimeMetricsSummary::default(); - self.session_telemetry.reset_runtime_metrics(); - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.update_task_running_state(); - self.status_state.retry_status_header = None; - if self.active_hook_cell.take().is_some() { - self.bump_active_cell_revision(); - } - self.status_state.pending_status_indicator_restore = false; - self.bottom_pane - .set_interrupt_hint_visible(/*visible*/ true); - self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; - self.set_status_header(String::from("Working")); - self.full_reasoning_buffer.clear(); - self.reasoning_buffer.clear(); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Running, - /*body*/ None, - ); - self.request_redraw(); - } - - fn on_task_complete( - &mut self, - last_agent_message: Option, - duration_ms: Option, - from_replay: bool, - ) { - self.input_queue.submit_pending_steers_after_interrupt = false; - // Use `last_agent_message` from the turn-complete notification as the copy - // source only when no earlier item-level event (AgentMessageItem, plan - // commit, review output) already recorded markdown for this turn. This - // prevents the final summary from overwriting a more specific source. - let sanitized_last_agent_message = last_agent_message - .as_deref() - .map(|message| parse_assistant_markdown(message).visible_markdown); - if let Some(message) = sanitized_last_agent_message - .as_ref() - .filter(|message| !message.is_empty()) - && !self.transcript.saw_copy_source_this_turn - { - self.record_agent_markdown(message); - } - // For desktop notifications: prefer the notification payload, fall back to - // the item-level copy source if present, otherwise send an empty string. - let notification_response = sanitized_last_agent_message - .as_ref() - .filter(|message| !message.is_empty()) - .cloned() - .or_else(|| { - if self.transcript.saw_copy_source_this_turn { - self.transcript.last_agent_markdown.clone() - } else { - None - } - }) - .unwrap_or_default(); - self.transcript.saw_copy_source_this_turn = false; - // If a stream is currently active, finalize it. - self.flush_answer_stream_with_separator(); - if let Some(mut controller) = self.plan_stream_controller.take() { - let had_live_tail = controller.has_live_tail(); - self.clear_active_stream_tail(); - let (cell, source) = controller.finalize(); - if !had_live_tail && let Some(cell) = cell { - self.add_boxed_history(cell); - } - if let Some(source) = source { - self.app_event_tx - .send(AppEvent::ConsolidateProposedPlan(source)); - } - } - self.flush_unified_exec_wait_streak(); - if !from_replay { - self.collect_runtime_metrics_delta(); - let runtime_metrics = - (!self.turn_runtime_metrics.is_empty()).then_some(self.turn_runtime_metrics); - let show_work_separator = self.transcript.had_work_activity - && (self.transcript.needs_final_message_separator || runtime_metrics.is_some()); - if show_work_separator || runtime_metrics.is_some() { - let elapsed_seconds = if show_work_separator { - duration_ms - .and_then(|duration_ms| u64::try_from(duration_ms).ok()) - .map(|duration_ms| duration_ms / 1_000) - .or_else(|| { - self.bottom_pane - .status_widget() - .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds) - }) - } else { - None - }; - self.add_to_history(history_cell::FinalMessageSeparator::new( - elapsed_seconds, - runtime_metrics, - )); - } - self.turn_runtime_metrics = RuntimeMetricsSummary::default(); - self.transcript.needs_final_message_separator = false; - self.transcript.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.status_state.pending_status_indicator_restore = false; - self.input_queue.user_turn_pending_start = false; - self.turn_lifecycle.finish(); - self.update_task_running_state(); - self.running_commands.clear(); - self.suppressed_exec_calls.clear(); - self.last_unified_wait = None; - self.unified_exec_wait_streak = None; - if !from_replay { - let body = Notification::agent_turn_preview(¬ification_response); - self.set_ambient_pet_notification(crate::pets::PetNotificationKind::Review, body); - } - self.request_redraw(); - - let had_pending_steers = !self.input_queue.pending_steers.is_empty(); - self.refresh_pending_input_preview(); - - if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { - self.maybe_prompt_plan_implementation(); - } - // Keep this flag for replayed completion events so a subsequent live TurnComplete can - // still show the prompt once after thread switch replay. - if !from_replay { - self.transcript.saw_plan_item_this_turn = false; - } - // If there is a queued user message, send exactly one now to begin the next turn. - let follow_up_started = self.maybe_send_next_queued_input(); - let active_goal_continuing = self - .current_goal_status - .as_ref() - .is_some_and(GoalStatusState::is_active); - // Emit a notification when the agent is truly waiting for the user. - // Queued follow-up input and active goal continuation both start the - // next turn immediately, so notifying at that boundary would feel like - // a false "needs attention". - if !follow_up_started && !active_goal_continuing { - self.notify(Notification::AgentTurnComplete { - response: notification_response, - }); - } - - self.maybe_show_pending_rate_limit_prompt(); - } - - fn maybe_prompt_plan_implementation(&mut self) { - if !self.collaboration_modes_enabled() { - return; - } - if self.has_queued_follow_up_messages() { - return; - } - if self.active_mode_kind() != ModeKind::Plan { - return; - } - if !self.transcript.saw_plan_item_this_turn { - return; - } - if !self.bottom_pane.no_modal_or_popup_active() { - return; - } - - if matches!( - self.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Pending - ) { - return; - } - - self.open_plan_implementation_prompt(); - } - - fn open_plan_implementation_prompt(&mut self) { - let default_mask = collaboration_modes::default_mode_mask(self.model_catalog.as_ref()); - let context_usage_label = self.plan_implementation_context_usage_label(); - - self.bottom_pane - .show_selection_view(plan_implementation::selection_view_params( - default_mask, - self.transcript.latest_proposed_plan_markdown.as_deref(), - context_usage_label.as_deref(), - )); - self.notify(Notification::PlanModePrompt { - title: PLAN_IMPLEMENTATION_TITLE.to_string(), - }); - } - - /// Returns a context-used label for the plan implementation prompt. - /// - /// The footer reports context remaining because it is ambient status, but - /// this prompt is asking whether to discard prior conversation state before - /// implementing a plan. Reporting used context makes the cleanup tradeoff - /// explicit. A fully fresh or unknown context window returns no label so - /// the clear-context option does not imply urgency without evidence. - fn plan_implementation_context_usage_label(&self) -> Option { - let info = self.token_info.as_ref()?; - let percent = self.context_remaining_percent(info); - - let used_tokens = self.context_used_tokens(info, percent.is_some()); - if let Some(percent) = percent { - let used_percent = 100 - percent.clamp(0, 100); - if used_percent <= 0 { - return None; - } - return Some(format!("{used_percent}% used")); - } - - if let Some(tokens) = used_tokens - && tokens > 0 - { - return Some(format!("{} used", format_tokens_compact(tokens))); - } - - None - } - - fn has_queued_follow_up_messages(&self) -> bool { - self.input_queue.has_queued_follow_up_messages() - } - - fn pop_next_queued_user_message( - &mut self, - ) -> Option<(QueuedUserMessage, UserMessageHistoryRecord)> { - if self.input_queue.rejected_steers_queue.is_empty() { - self.input_queue - .queued_user_messages - .pop_front() - .map(|user_message| { - let history_record = self - .input_queue - .queued_user_message_history_records - .pop_front() - .unwrap_or(UserMessageHistoryRecord::UserMessageText); - (user_message, history_record) - }) - } else { - let rejected_messages = self - .input_queue - .rejected_steers_queue - .drain(..) - .collect::>(); - let mut history_records = self - .input_queue - .rejected_steer_history_records - .drain(..) - .collect::>(); - history_records.resize( - rejected_messages.len(), - UserMessageHistoryRecord::UserMessageText, - ); - let (message, history_record) = merge_user_messages_with_history_record( - rejected_messages - .into_iter() - .zip(history_records) - .collect::>(), - ); - Some((QueuedUserMessage::from(message), history_record)) - } - } - - fn pop_latest_queued_user_message(&mut self) -> Option { - if let Some(user_message) = self.input_queue.queued_user_messages.pop_back() { - let history_record = self - .input_queue - .queued_user_message_history_records - .pop_back() - .unwrap_or(UserMessageHistoryRecord::UserMessageText); - Some(user_message_for_restore( - user_message.into_user_message(), - &history_record, - )) - } else { - let user_message = self.input_queue.rejected_steers_queue.pop_back()?; - let history_record = self - .input_queue - .rejected_steer_history_records - .pop_back() - .unwrap_or(UserMessageHistoryRecord::UserMessageText); - Some(user_message_for_restore(user_message, &history_record)) - } - } - - pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { - let Some(pending_steer) = self.input_queue.pending_steers.pop_front() else { - tracing::warn!( - "received active-turn-not-steerable error without a matching pending steer" - ); - return false; - }; - self.input_queue - .rejected_steers_queue - .push_back(pending_steer.user_message); - self.input_queue - .rejected_steer_history_records - .push_back(pending_steer.history_record); - self.refresh_pending_input_preview(); - true - } - - fn handle_app_server_steer_rejected_error( - &mut self, - codex_error_info: &AppServerCodexErrorInfo, - ) -> bool { - matches!( - codex_error_info, - AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. } - ) && self.enqueue_rejected_steer() - } - pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { @@ -2627,6 +1395,7 @@ impl ChatWidget { self.config.memories.use_memories, self.config.memories.generate_memories, self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), ); self.bottom_pane.show_view(Box::new(view)); } @@ -2818,1096 +1587,6 @@ impl ChatWidget { } self.refresh_status_line(); } - /// Finalize any active exec as failed and stop/clear agent-turn UI state. - /// - /// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup - /// and should continue to drive the bottom-pane running indicator while it is in progress. - fn finalize_turn(&mut self) { - // Drop preview-only stream tail content on any termination path before - // failed-cell finalization, so transient tail cells are never persisted. - self.clear_active_stream_tail(); - // Ensure any spinner is replaced by a red ✗ and flushed into history. - self.finalize_active_cell_as_failed(); - // Turn-scoped hook rows are transient live state; once the turn is over, - // do not leave an orphaned running row behind if no matching completion - // event arrived before cancellation. - if self.active_hook_cell.take().is_some() { - self.bump_active_cell_revision(); - } - // Reset running state and clear streaming buffers. - self.input_queue.user_turn_pending_start = false; - self.turn_lifecycle.finish(); - self.update_task_running_state(); - self.running_commands.clear(); - self.suppressed_exec_calls.clear(); - self.last_unified_wait = None; - self.unified_exec_wait_streak = None; - self.adaptive_chunking.reset(); - self.stream_controller = None; - self.plan_stream_controller = None; - self.status_state.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(); - } - - fn on_server_overloaded_error(&mut self, message: String) { - self.input_queue.submit_pending_steers_after_interrupt = false; - self.finalize_turn(); - - let message = if message.trim().is_empty() { - "Codex is currently experiencing high load.".to_string() - } else { - message - }; - - self.add_to_history(history_cell::new_warning_event(message)); - self.request_redraw(); - self.maybe_send_next_queued_input(); - } - - fn on_error(&mut self, message: String) { - self.input_queue.submit_pending_steers_after_interrupt = false; - self.finalize_turn(); - self.add_to_history(history_cell::new_error_event(message)); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Failed, - /*body*/ None, - ); - self.request_redraw(); - - // After an error ends the turn, try sending the next queued input. - self.maybe_send_next_queued_input(); - } - - fn on_cyber_policy_error(&mut self) { - self.input_queue.submit_pending_steers_after_interrupt = false; - self.finalize_turn(); - self.add_to_history(history_cell::new_cyber_policy_error_event()); - self.request_redraw(); - - // After an error ends the turn, try sending the next queued input. - self.maybe_send_next_queued_input(); - } - - fn workspace_owner_usage_nudge_enabled(&self) -> bool { - self.config - .features - .enabled(Feature::WorkspaceOwnerUsageNudge) - } - - fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) { - if !self.workspace_owner_usage_nudge_enabled() { - self.on_error(message); - return; - } - - let rate_limit_reached_type = self.codex_rate_limit_reached_type.map(|kind| { - if matches!(error_kind, RateLimitErrorKind::UsageLimit) { - match kind { - RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { - RateLimitReachedType::WorkspaceOwnerUsageLimitReached - } - RateLimitReachedType::WorkspaceMemberCreditsDepleted => { - RateLimitReachedType::WorkspaceMemberUsageLimitReached - } - other => other, - } - } else { - kind - } - }); - self.codex_rate_limit_reached_type = rate_limit_reached_type; - - match rate_limit_reached_type { - Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => { - self.on_error( - "You're out of credits. Your workspace is out of credits. Add credits to continue using Codex." - .to_string(), - ); - } - Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) => { - self.on_error( - "Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex." - .to_string(), - ); - } - Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) => { - self.on_error(message); - self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::Credits); - } - Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) => { - self.on_error(message); - self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::UsageLimit); - } - Some(RateLimitReachedType::RateLimitReached) | None => { - self.on_error(message); - } - } - } - - fn handle_non_retry_error( - &mut self, - message: String, - codex_error_info: Option, - ) { - if codex_error_info - .as_ref() - .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) - { - } else if codex_error_info - .as_ref() - .is_some_and(is_app_server_cyber_policy_error) - { - self.on_cyber_policy_error(); - } else if let Some(info) = codex_error_info - .as_ref() - .and_then(app_server_rate_limit_error_kind) - { - match info { - RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message), - RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { - self.on_rate_limit_error(info, message) - } - } - } else { - self.on_error(message); - } - } - - fn on_warning(&mut self, message: impl Into) { - let message = message.into(); - if !self.warning_display_state.should_display(&message) { - return; - } - self.add_to_history(history_cell::new_warning_event(message)); - self.request_redraw(); - } - - fn on_app_server_model_verification(&mut self, verifications: &[AppServerModelVerification]) { - if verifications.contains(&AppServerModelVerification::TrustedAccessForCyber) { - self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING); - } - } - - /// Handle a turn aborted due to user interrupt (Esc), budget exhaustion, - /// or review completion. - /// When there are queued user messages, restore them into the composer - /// separated by newlines rather than auto‑submitting the next one. - fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { - // Finalize, log a gentle prompt, and clear running state. - self.finalize_turn(); - let send_pending_steers_immediately = - self.input_queue.submit_pending_steers_after_interrupt; - self.input_queue.submit_pending_steers_after_interrupt = false; - if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress { - if send_pending_steers_immediately { - self.add_to_history(history_cell::new_info_event( - "Model interrupted to submit steer instructions.".to_owned(), - /*hint*/ None, - )); - } else { - self.add_to_history(history_cell::new_error_event( - self.interrupted_turn_message(reason), - )); - } - } - - // The server has already discarded pending input by the time the - // interrupted turn reaches the UI, so any unacknowledged steers still - // tracked here must be restored locally instead of waiting for a later commit. - if send_pending_steers_immediately { - let pending_steers = self - .input_queue - .pending_steers - .drain(..) - .map(|pending| (pending.user_message, pending.history_record)) - .collect::>(); - if !pending_steers.is_empty() { - let (user_message, history_record) = - merge_user_messages_with_history_record(pending_steers); - self.submit_user_message_with_history_record(user_message, history_record); - } else if let Some(combined) = self.drain_pending_messages_for_restore() { - self.restore_user_message_to_composer(combined); - } - } else if let Some(combined) = self.drain_pending_messages_for_restore() { - self.restore_user_message_to_composer(combined); - } - self.refresh_pending_input_preview(); - - self.request_redraw(); - } - - /// Merge pending steers, queued drafts, and the current composer state into a single message. - /// - /// Each pending message numbers attachments from `[Image #1]` relative to its own remote - /// images. When we concatenate multiple messages after interrupt, we must renumber local-image - /// placeholders in a stable order and rebase text element byte ranges so the restored composer - /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to - /// restore. - fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.input_queue.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { - return None; - } - - let existing_message = UserMessage { - text: self.bottom_pane.composer_text(), - text_elements: self.bottom_pane.composer_text_elements(), - local_images: self.bottom_pane.composer_local_images(), - remote_image_urls: self.bottom_pane.remote_image_urls(), - mention_bindings: self.bottom_pane.composer_mention_bindings(), - }; - - let rejected_messages = self - .input_queue - .rejected_steers_queue - .drain(..) - .collect::>(); - let mut rejected_history_records = self - .input_queue - .rejected_steer_history_records - .drain(..) - .collect::>(); - rejected_history_records.resize( - rejected_messages.len(), - UserMessageHistoryRecord::UserMessageText, - ); - let mut to_merge: Vec = rejected_messages - .into_iter() - .zip(rejected_history_records.iter()) - .map(|(message, history_record)| user_message_for_restore(message, history_record)) - .collect(); - to_merge.extend( - self.input_queue - .pending_steers - .drain(..) - .map(|steer| user_message_for_restore(steer.user_message, &steer.history_record)), - ); - let queued_messages = self - .input_queue - .queued_user_messages - .drain(..) - .collect::>(); - let mut queued_history_records = self - .input_queue - .queued_user_message_history_records - .drain(..) - .collect::>(); - queued_history_records.resize( - queued_messages.len(), - UserMessageHistoryRecord::UserMessageText, - ); - to_merge.extend( - queued_messages - .into_iter() - .zip(queued_history_records.iter()) - .map(|(message, history_record)| { - user_message_for_restore(message.into_user_message(), history_record) - }), - ); - if !existing_message.text.is_empty() - || !existing_message.local_images.is_empty() - || !existing_message.remote_image_urls.is_empty() - { - to_merge.push(existing_message); - } - - Some(merge_user_messages(to_merge)) - } - - pub(crate) fn restore_user_message_to_composer(&mut self, user_message: UserMessage) { - let UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings, - } = user_message; - let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); - self.set_remote_image_urls(remote_image_urls); - self.bottom_pane.set_composer_text_with_mention_bindings( - text, - text_elements, - local_image_paths, - mention_bindings, - ); - } - - pub(crate) fn capture_thread_input_state(&self) -> Option { - let composer = ThreadComposerState { - text: self.bottom_pane.composer_text(), - text_elements: self.bottom_pane.composer_text_elements(), - local_images: self.bottom_pane.composer_local_images(), - remote_image_urls: self.bottom_pane.remote_image_urls(), - mention_bindings: self.bottom_pane.composer_mention_bindings(), - pending_pastes: self.bottom_pane.composer_pending_pastes(), - }; - Some(ThreadInputState { - composer: composer.has_content().then_some(composer), - pending_steers: self - .input_queue - .pending_steers - .iter() - .map(|pending| pending.user_message.clone()) - .collect(), - pending_steer_history_records: self - .input_queue - .pending_steers - .iter() - .map(|pending| pending.history_record.clone()) - .collect(), - pending_steer_compare_keys: self - .input_queue - .pending_steers - .iter() - .map(|pending| pending.compare_key.clone()) - .collect(), - rejected_steers_queue: self.input_queue.rejected_steers_queue.clone(), - rejected_steer_history_records: self.input_queue.rejected_steer_history_records.clone(), - queued_user_messages: self.input_queue.queued_user_messages.clone(), - queued_user_message_history_records: self - .input_queue - .queued_user_message_history_records - .clone(), - user_turn_pending_start: self.input_queue.user_turn_pending_start, - current_collaboration_mode: self.current_collaboration_mode.clone(), - active_collaboration_mask: self.active_collaboration_mask.clone(), - task_running: self.bottom_pane.is_task_running(), - agent_turn_running: self.turn_lifecycle.agent_turn_running, - }) - } - - pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { - let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); - if let Some(input_state) = input_state { - self.current_collaboration_mode = input_state.current_collaboration_mode; - self.active_collaboration_mask = input_state.active_collaboration_mask; - self.turn_lifecycle - .restore_running(input_state.agent_turn_running, Instant::now()); - self.input_queue.user_turn_pending_start = input_state.user_turn_pending_start; - self.update_collaboration_mode_indicator(); - self.refresh_model_dependent_surfaces(); - if let Some(composer) = input_state.composer { - let local_image_paths = composer - .local_images - .into_iter() - .map(|img| img.path) - .collect(); - self.set_remote_image_urls(composer.remote_image_urls); - self.bottom_pane.set_composer_text_with_mention_bindings( - composer.text, - composer.text_elements, - local_image_paths, - composer.mention_bindings, - ); - self.bottom_pane - .set_composer_pending_pastes(composer.pending_pastes); - } else { - self.set_remote_image_urls(Vec::new()); - self.bottom_pane.set_composer_text_with_mention_bindings( - String::new(), - Vec::new(), - Vec::new(), - Vec::new(), - ); - self.bottom_pane.set_composer_pending_pastes(Vec::new()); - } - let mut pending_steer_history_records = input_state.pending_steer_history_records; - pending_steer_history_records.resize( - input_state.pending_steers.len(), - UserMessageHistoryRecord::UserMessageText, - ); - let mut pending_steer_compare_keys = input_state.pending_steer_compare_keys; - self.input_queue.pending_steers = input_state - .pending_steers - .into_iter() - .zip(pending_steer_history_records) - .map(|(user_message, history_record)| PendingSteer { - compare_key: pending_steer_compare_keys.pop_front().unwrap_or_else(|| { - PendingSteerCompareKey { - message: user_message.text.clone(), - image_count: user_message.local_images.len() - + user_message.remote_image_urls.len(), - } - }), - history_record, - user_message, - }) - .collect(); - self.input_queue.rejected_steers_queue = input_state.rejected_steers_queue; - self.input_queue.rejected_steer_history_records = - input_state.rejected_steer_history_records; - self.input_queue.rejected_steer_history_records.resize( - self.input_queue.rejected_steers_queue.len(), - UserMessageHistoryRecord::UserMessageText, - ); - self.input_queue.queued_user_messages = input_state.queued_user_messages; - self.input_queue.queued_user_message_history_records = - input_state.queued_user_message_history_records; - self.input_queue.queued_user_message_history_records.resize( - self.input_queue.queued_user_messages.len(), - UserMessageHistoryRecord::UserMessageText, - ); - } else { - self.turn_lifecycle - .restore_running(/*running*/ false, Instant::now()); - self.input_queue.clear(); - self.set_remote_image_urls(Vec::new()); - self.bottom_pane.set_composer_text_with_mention_bindings( - String::new(), - Vec::new(), - Vec::new(), - Vec::new(), - ); - self.bottom_pane.set_composer_pending_pastes(Vec::new()); - } - self.turn_lifecycle - .restore_running(self.turn_lifecycle.agent_turn_running, Instant::now()); - self.update_task_running_state(); - if restored_task_running && !self.bottom_pane.is_task_running() { - self.bottom_pane.set_task_running(/*running*/ true); - self.refresh_status_surfaces(); - } - self.refresh_pending_input_preview(); - self.request_redraw(); - } - - pub(crate) fn set_queue_autosend_suppressed(&mut self, suppressed: bool) { - self.input_queue.suppress_queue_autosend = suppressed; - } - - fn on_plan_update(&mut self, update: UpdatePlanArgs) { - self.transcript.saw_plan_update_this_turn = true; - let total = update.plan.len(); - let completed = update - .plan - .iter() - .filter(|item| match &item.status { - StepStatus::Completed => true, - StepStatus::Pending | StepStatus::InProgress => false, - }) - .count(); - self.transcript.last_plan_progress = (total > 0).then_some((completed, total)); - self.refresh_status_surfaces(); - self.add_to_history(history_cell::new_plan_update(update)); - } - - fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) { - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_exec_approval(ev), - |s| s.handle_exec_approval_now(ev2), - ); - } - - fn on_apply_patch_approval_request(&mut self, _id: String, ev: ApplyPatchApprovalRequestEvent) { - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_apply_patch_approval(ev), - |s| s.handle_apply_patch_approval_now(ev2), - ); - } - - /// Handle guardian review lifecycle events for the current thread. - /// - /// In-progress assessments temporarily own the live status footer so the - /// user can see what is being reviewed, including parallel review - /// aggregation. Terminal assessments clear or update that footer state and - /// render the final approved/denied history cell when guardian returns a - /// decision. - fn on_guardian_assessment(&mut self, ev: GuardianAssessmentEvent) { - let permission_request_summary = |subject: &str, reason: &Option| { - reason - .as_deref() - .map(str::trim) - .filter(|reason| !reason.is_empty()) - .map(|reason| format!("{subject}: {reason}")) - .unwrap_or_else(|| subject.to_string()) - }; - let guardian_action_summary = |action: &GuardianAssessmentAction| match action { - GuardianAssessmentAction::Command { command, .. } => Some(command.clone()), - GuardianAssessmentAction::Execve { program, argv, .. } => { - let command = if argv.is_empty() { - vec![program.clone()] - } else { - argv.clone() - }; - shlex::try_join(command.iter().map(String::as_str)) - .ok() - .or_else(|| Some(command.join(" "))) - } - GuardianAssessmentAction::ApplyPatch { files, .. } => Some(if files.len() == 1 { - format!("apply_patch touching {}", files[0].display()) - } else { - format!("apply_patch touching {} files", files.len()) - }), - GuardianAssessmentAction::NetworkAccess { target, .. } => { - Some(format!("network access to {target}")) - } - GuardianAssessmentAction::McpToolCall { - server, - tool_name, - connector_name, - .. - } => { - let label = connector_name.as_deref().unwrap_or(server.as_str()); - Some(format!("MCP {tool_name} on {label}")) - } - GuardianAssessmentAction::RequestPermissions { reason, .. } => { - Some(permission_request_summary("permission request", reason)) - } - }; - let guardian_command = |action: &GuardianAssessmentAction| match action { - GuardianAssessmentAction::Command { command, .. } => shlex::split(command) - .filter(|command| !command.is_empty()) - .or_else(|| Some(vec![command.clone()])), - GuardianAssessmentAction::Execve { program, argv, .. } => Some(if argv.is_empty() { - vec![program.clone()] - } else { - argv.clone() - }) - .filter(|command| !command.is_empty()), - GuardianAssessmentAction::ApplyPatch { .. } - | GuardianAssessmentAction::NetworkAccess { .. } - | GuardianAssessmentAction::McpToolCall { .. } - | GuardianAssessmentAction::RequestPermissions { .. } => None, - }; - - if ev.status == GuardianAssessmentStatus::InProgress - && let Some(detail) = guardian_action_summary(&ev.action) - { - // In-progress assessments own the live footer state while the - // review is pending. Parallel reviews are aggregated into one - // footer summary by `PendingGuardianReviewStatus`. - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane - .set_interrupt_hint_visible(/*visible*/ true); - self.status_state - .pending_guardian_review_status - .start_or_update(ev.id.clone(), detail); - if let Some(status) = self - .status_state - .pending_guardian_review_status - .status_indicator_state() - { - self.set_status( - status.header, - status.details, - StatusDetailsCapitalization::Preserve, - status.details_max_lines, - ); - } - self.request_redraw(); - return; - } - - // Terminal assessments remove the matching pending footer entry first, - // then render the final approved/denied history cell below. - if self - .status_state - .pending_guardian_review_status - .finish(&ev.id) - { - if let Some(status) = self - .status_state - .pending_guardian_review_status - .status_indicator_state() - { - self.set_status( - status.header, - status.details, - StatusDetailsCapitalization::Preserve, - status.details_max_lines, - ); - } else if self.status_state.current_status.is_guardian_review() { - self.set_status_header(String::from("Working")); - } - } else if self.status_state.pending_guardian_review_status.is_empty() - && self.status_state.current_status.is_guardian_review() - { - self.set_status_header(String::from("Working")); - } - - if ev.status == GuardianAssessmentStatus::Approved { - let cell = if let Some(command) = guardian_command(&ev.action) { - history_cell::new_approval_decision_cell( - command, - crate::history_cell::ReviewDecision::Approved, - history_cell::ApprovalDecisionActor::Guardian, - ) - } else if let Some(summary) = guardian_action_summary(&ev.action) { - history_cell::new_guardian_approved_action_request(summary) - } else { - let summary = serde_json::to_string(&ev.action) - .unwrap_or_else(|_| "".to_string()); - history_cell::new_guardian_approved_action_request(summary) - }; - - self.add_boxed_history(cell); - self.request_redraw(); - return; - } - - if ev.status == GuardianAssessmentStatus::TimedOut { - let cell = if let Some(command) = guardian_command(&ev.action) { - history_cell::new_approval_decision_cell( - command, - crate::history_cell::ReviewDecision::TimedOut, - history_cell::ApprovalDecisionActor::Guardian, - ) - } else { - match &ev.action { - GuardianAssessmentAction::ApplyPatch { files, .. } => { - let files = files - .iter() - .map(|path| path.display().to_string()) - .collect::>(); - history_cell::new_guardian_timed_out_patch_request(files) - } - GuardianAssessmentAction::McpToolCall { - server, tool_name, .. - } => history_cell::new_guardian_timed_out_action_request(format!( - "codex could call MCP tool {server}.{tool_name}" - )), - GuardianAssessmentAction::NetworkAccess { target, .. } => { - history_cell::new_guardian_timed_out_action_request(format!( - "codex could access {target}" - )) - } - GuardianAssessmentAction::RequestPermissions { reason, .. } => { - history_cell::new_guardian_timed_out_action_request( - permission_request_summary("codex could request permissions", reason), - ) - } - GuardianAssessmentAction::Command { .. } => unreachable!(), - GuardianAssessmentAction::Execve { .. } => unreachable!(), - } - }; - - self.add_boxed_history(cell); - self.request_redraw(); - return; - } - - if ev.status != GuardianAssessmentStatus::Denied { - return; - } - self.review.recent_auto_review_denials.push(ev.clone()); - let cell = if let Some(command) = guardian_command(&ev.action) { - history_cell::new_approval_decision_cell( - command, - crate::history_cell::ReviewDecision::Denied, - history_cell::ApprovalDecisionActor::Guardian, - ) - } else { - match &ev.action { - GuardianAssessmentAction::ApplyPatch { files, .. } => { - let files = files - .iter() - .map(|path| path.display().to_string()) - .collect::>(); - history_cell::new_guardian_denied_patch_request(files) - } - GuardianAssessmentAction::McpToolCall { - server, tool_name, .. - } => history_cell::new_guardian_denied_action_request(format!( - "codex to call MCP tool {server}.{tool_name}" - )), - GuardianAssessmentAction::NetworkAccess { target, .. } => { - history_cell::new_guardian_denied_action_request(format!( - "codex to access {target}" - )) - } - GuardianAssessmentAction::RequestPermissions { reason, .. } => { - history_cell::new_guardian_denied_action_request(permission_request_summary( - "codex to request permissions", - reason, - )) - } - GuardianAssessmentAction::Command { .. } => unreachable!(), - GuardianAssessmentAction::Execve { .. } => unreachable!(), - } - }; - - self.add_boxed_history(cell); - self.request_redraw(); - } - - fn on_elicitation_request( - &mut self, - request_id: AppServerRequestId, - params: McpServerElicitationRequestParams, - ) { - let request_id2 = request_id.clone(); - let params2 = params.clone(); - self.defer_or_handle( - |q| q.push_elicitation(request_id, params), - |s| s.handle_elicitation_request_now(request_id2, params2), - ); - } - - fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) { - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_user_input(ev), - |s| s.handle_request_user_input_now(ev2), - ); - } - - fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) { - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_request_permissions(ev), - |s| s.handle_request_permissions_now(ev2), - ); - } - - fn on_command_execution_started(&mut self, item: ThreadItem) { - let ThreadItem::CommandExecution { - id, - command, - process_id, - source, - command_actions, - .. - } = &item - else { - return; - }; - let (_command, parsed_cmd) = command_execution_command_and_parsed(command, command_actions); - self.flush_answer_stream_with_separator(); - if is_unified_exec_source(*source) { - if *source == ExecCommandSource::UnifiedExecStartup { - self.track_unified_exec_process_begin(id, process_id.as_deref(), command); - } - if !self.bottom_pane.is_task_running() { - return; - } - // Unified exec may be parsed as Unknown; keep the working indicator visible regardless. - self.bottom_pane.ensure_status_indicator(); - if !is_standard_tool_call(&parsed_cmd) { - return; - } - } - let item2 = item.clone(); - self.defer_or_handle( - |q| q.push_item_started(item), - |s| s.handle_command_execution_started_now(item2), - ); - } - - fn on_exec_command_output_delta(&mut self, call_id: &str, delta: &str) { - self.track_unified_exec_output_chunk(call_id, delta.as_bytes()); - if !self.bottom_pane.is_task_running() { - return; - } - - let Some(cell) = self - .transcript - .active_cell - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - else { - return; - }; - - if cell.append_output(call_id, delta) { - self.bump_active_cell_revision(); - self.request_redraw(); - } - } - - fn on_terminal_interaction(&mut self, process_id: String, stdin: String) { - if !self.bottom_pane.is_task_running() { - return; - } - self.flush_answer_stream_with_separator(); - let command_display = self - .unified_exec_processes - .iter() - .find(|process| process.key == process_id) - .map(|process| process.command_display.clone()); - if stdin.is_empty() { - // Empty stdin means we are polling for background output. - // Surface this in the status indicator (single "waiting" surface) instead of - // the transcript. Keep the header short so the interrupt hint remains visible. - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane - .set_interrupt_hint_visible(/*visible*/ true); - self.status_state.terminal_title_status_kind = - TerminalTitleStatusKind::WaitingForBackgroundTerminal; - self.set_status( - "Waiting for background terminal".to_string(), - command_display.clone(), - StatusDetailsCapitalization::Preserve, - /*details_max_lines*/ 1, - ); - match &mut self.unified_exec_wait_streak { - Some(wait) if wait.process_id == process_id => { - wait.update_command_display(command_display); - } - Some(_) => { - self.flush_unified_exec_wait_streak(); - self.unified_exec_wait_streak = - Some(UnifiedExecWaitStreak::new(process_id, command_display)); - } - None => { - self.unified_exec_wait_streak = - Some(UnifiedExecWaitStreak::new(process_id, command_display)); - } - } - self.request_redraw(); - } else { - if self - .unified_exec_wait_streak - .as_ref() - .is_some_and(|wait| wait.process_id == process_id) - { - self.flush_unified_exec_wait_streak(); - } - self.add_to_history(history_cell::new_unified_exec_interaction( - command_display, - stdin, - )); - } - } - - fn on_patch_apply_begin(&mut self, changes: HashMap) { - self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd)); - } - - fn on_view_image_tool_call(&mut self, path: AbsolutePathBuf) { - self.flush_answer_stream_with_separator(); - self.add_to_history(history_cell::new_view_image_tool_call( - path, - &self.config.cwd, - )); - self.request_redraw(); - } - - fn on_image_generation_begin(&mut self) { - self.flush_answer_stream_with_separator(); - } - - fn on_image_generation_end( - &mut self, - call_id: String, - revised_prompt: Option, - saved_path: Option, - ) { - self.flush_answer_stream_with_separator(); - self.add_to_history(history_cell::new_image_generation_call( - call_id, - revised_prompt, - saved_path, - )); - self.request_redraw(); - } - - fn on_file_change_completed(&mut self, item: ThreadItem) { - let item2 = item.clone(); - self.defer_or_handle( - |q| q.push_item_completed(item), - |s| s.handle_file_change_completed_now(item2), - ); - } - - fn on_command_execution_completed(&mut self, item: ThreadItem) { - let ThreadItem::CommandExecution { - id, - process_id, - source, - .. - } = &item - else { - return; - }; - if is_unified_exec_source(*source) { - if let Some(process_id) = process_id.as_deref() - && self - .unified_exec_wait_streak - .as_ref() - .is_some_and(|wait| wait.process_id == process_id) - { - self.flush_unified_exec_wait_streak(); - } - self.track_unified_exec_process_end(id, process_id.as_deref()); - if !self.bottom_pane.is_task_running() { - return; - } - } - let item2 = item.clone(); - self.defer_or_handle( - |q| q.push_item_completed(item), - |s| s.handle_command_execution_completed_now(item2), - ); - } - - fn track_unified_exec_process_begin( - &mut self, - call_id: &str, - process_id: Option<&str>, - command: &str, - ) { - let key = process_id.unwrap_or(call_id).to_string(); - let command = split_command_string(command); - let command_display = strip_bash_lc_and_escape(&command); - if let Some(existing) = self - .unified_exec_processes - .iter_mut() - .find(|process| process.key == key) - { - existing.call_id = call_id.to_string(); - existing.command_display = command_display; - existing.recent_chunks.clear(); - } else { - self.unified_exec_processes.push(UnifiedExecProcessSummary { - key, - call_id: call_id.to_string(), - command_display, - recent_chunks: Vec::new(), - }); - } - self.sync_unified_exec_footer(); - } - - fn track_unified_exec_process_end(&mut self, call_id: &str, process_id: Option<&str>) { - let key = process_id.unwrap_or(call_id); - let before = self.unified_exec_processes.len(); - self.unified_exec_processes - .retain(|process| process.key != key); - if self.unified_exec_processes.len() != before { - self.sync_unified_exec_footer(); - } - } - - fn sync_unified_exec_footer(&mut self) { - let processes = self - .unified_exec_processes - .iter() - .map(|process| process.command_display.clone()) - .collect(); - self.bottom_pane.set_unified_exec_processes(processes); - } - - /// Record recent stdout/stderr lines for the unified exec footer. - fn track_unified_exec_output_chunk(&mut self, call_id: &str, chunk: &[u8]) { - let Some(process) = self - .unified_exec_processes - .iter_mut() - .find(|process| process.call_id == call_id) - else { - return; - }; - - let text = String::from_utf8_lossy(chunk); - for line in text - .lines() - .map(str::trim_end) - .filter(|line| !line.is_empty()) - { - process.recent_chunks.push(line.to_string()); - } - - const MAX_RECENT_CHUNKS: usize = 3; - if process.recent_chunks.len() > MAX_RECENT_CHUNKS { - let drop_count = process.recent_chunks.len() - MAX_RECENT_CHUNKS; - process.recent_chunks.drain(0..drop_count); - } - } - - fn on_mcp_tool_call_started(&mut self, item: ThreadItem) { - let item2 = item.clone(); - self.defer_or_handle( - |q| q.push_item_started(item), - |s| s.handle_mcp_tool_call_started_now(item2), - ); - } - - fn on_mcp_tool_call_completed(&mut self, item: ThreadItem) { - let item2 = item.clone(); - self.defer_or_handle( - |q| q.push_item_completed(item), - |s| s.handle_mcp_tool_call_completed_now(item2), - ); - } - - fn on_web_search_begin(&mut self, call_id: String) { - self.flush_answer_stream_with_separator(); - self.flush_active_cell(); - self.transcript.active_cell = Some(Box::new(history_cell::new_active_web_search_call( - call_id, - String::new(), - self.config.animations, - ))); - self.bump_active_cell_revision(); - self.request_redraw(); - } - - fn on_web_search_end( - &mut self, - call_id: String, - query: String, - action: codex_app_server_protocol::WebSearchAction, - ) { - self.flush_answer_stream_with_separator(); - let mut handled = false; - if let Some(cell) = self - .transcript - .active_cell - .as_mut() - .and_then(|cell| cell.as_any_mut().downcast_mut::()) - && cell.call_id() == call_id - { - cell.update(action.clone(), query.clone()); - cell.complete(); - self.bump_active_cell_revision(); - self.flush_active_cell(); - handled = true; - } - - if !handled { - self.add_to_history(history_cell::new_web_search_call(call_id, query, action)); - } - self.transcript.had_work_activity = true; - } - - fn on_collab_event(&mut self, cell: PlainHistoryCell) { - self.flush_answer_stream_with_separator(); - self.add_to_history(cell); - self.request_redraw(); - } - - fn on_collab_agent_tool_call(&mut self, item: ThreadItem) { - let ThreadItem::CollabAgentToolCall { - id, tool, status, .. - } = &item - else { - return; - }; - if matches!(tool, CollabAgentTool::SpawnAgent) - && let Some(spawn_request) = multi_agents::spawn_request_summary(&item) - { - self.pending_collab_spawn_requests - .insert(id.clone(), spawn_request); - } - - let cached_spawn_request = if matches!(tool, CollabAgentTool::SpawnAgent) - && !matches!(status, CollabAgentToolCallStatus::InProgress) - { - self.pending_collab_spawn_requests.remove(id) - } else { - None - }; - - if let Some(cell) = multi_agents::tool_call_history_cell( - &item, - cached_spawn_request.as_ref(), - |thread_id| self.collab_agent_metadata(thread_id), - ) { - self.on_collab_event(cell); - } - } pub(crate) fn handle_history_entry_response(&mut self, event: HistoryLookupResponse) { let HistoryLookupResponse { @@ -3919,161 +1598,6 @@ impl ChatWidget { .on_history_entry_response(log_id, offset, entry); } - fn on_shutdown_complete(&mut self) { - self.request_immediate_exit(); - } - - fn on_turn_diff(&mut self, unified_diff: String) { - debug!("TurnDiffEvent: {unified_diff}"); - self.refresh_status_line(); - } - - fn interrupted_turn_message(&self, reason: TurnAbortReason) -> String { - if reason == TurnAbortReason::BudgetLimited { - return "Goal budget reached - the turn was stopped.".to_string(); - } - - "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_string() - } - - fn on_deprecation_notice(&mut self, summary: String, details: Option) { - self.add_to_history(history_cell::new_deprecation_notice(summary, details)); - self.request_redraw(); - } - - fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) { - self.flush_answer_stream_with_separator(); - self.flush_completed_hook_output(); - match self.active_hook_cell.as_mut() { - Some(cell) => { - cell.start_run(run); - self.bump_active_cell_revision(); - } - None => { - self.active_hook_cell = Some(history_cell::new_active_hook_cell( - run, - self.config.animations, - )); - self.bump_active_cell_revision(); - } - } - self.request_redraw(); - } - - fn on_hook_completed(&mut self, completed: codex_app_server_protocol::HookRunSummary) { - let completed_existing_run = self - .active_hook_cell - .as_mut() - .map(|cell| cell.complete_run(completed.clone())) - .unwrap_or(false); - if completed_existing_run { - self.bump_active_cell_revision(); - } else { - match self.active_hook_cell.as_mut() { - Some(cell) => { - cell.add_completed_run(completed); - self.bump_active_cell_revision(); - } - None => { - let cell = - history_cell::new_completed_hook_cell(completed, self.config.animations); - if !cell.is_empty() { - self.active_hook_cell = Some(cell); - self.bump_active_cell_revision(); - } - } - } - } - self.flush_completed_hook_output(); - self.finish_active_hook_cell_if_idle(); - self.request_redraw(); - } - - fn flush_completed_hook_output(&mut self) { - let Some(completed_cell) = self - .active_hook_cell - .as_mut() - .and_then(HookCell::take_completed_persistent_runs) - else { - return; - }; - let active_cell_is_empty = self - .active_hook_cell - .as_ref() - .is_some_and(HookCell::is_empty); - if active_cell_is_empty { - self.active_hook_cell = None; - } - self.bump_active_cell_revision(); - self.transcript.needs_final_message_separator = true; - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(completed_cell))); - } - - fn finish_active_hook_cell_if_idle(&mut self) { - let Some(cell) = self.active_hook_cell.as_ref() else { - return; - }; - if cell.is_empty() { - self.active_hook_cell = None; - self.bump_active_cell_revision(); - return; - } - if cell.should_flush() - && let Some(cell) = self.active_hook_cell.take() - { - self.bump_active_cell_revision(); - self.transcript.needs_final_message_separator = true; - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(cell))); - } - } - - fn update_due_hook_visibility(&mut self) { - let Some(cell) = self.active_hook_cell.as_mut() else { - return; - }; - let now = Instant::now(); - if cell.advance_time(now) { - self.bump_active_cell_revision(); - } - self.finish_active_hook_cell_if_idle(); - } - - fn schedule_hook_timer_if_needed(&self) { - if self.config.animations - && self - .active_hook_cell - .as_ref() - .is_some_and(HookCell::has_visible_running_run) - { - self.frame_requester - .schedule_frame_in(Duration::from_millis(50)); - } - - let Some(deadline) = self - .active_hook_cell - .as_ref() - .and_then(HookCell::next_timer_deadline) - else { - return; - }; - let delay = deadline.saturating_duration_since(Instant::now()); - self.frame_requester.schedule_frame_in(delay); - } - - fn on_stream_error(&mut self, message: String, additional_details: Option) { - self.status_state.remember_retry_status_header(); - self.bottom_pane.ensure_status_indicator(); - self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; - self.set_status( - message, - additional_details, - StatusDetailsCapitalization::CapitalizeFirst, - STATUS_DETAILS_DEFAULT_MAX_LINES, - ); - } - pub(crate) fn pre_draw_tick(&mut self) { self.update_due_hook_visibility(); self.schedule_hook_timer_if_needed(); @@ -4093,668 +1617,6 @@ impl ChatWidget { } } - /// Handle completion of an `AgentMessage` turn item. - /// - /// Commentary completion sets a deferred restore flag so the status row - /// returns once stream queues are idle. Final-answer completion (or absent - /// phase for legacy models) clears the flag to preserve historical behavior. - fn on_agent_message_item_completed(&mut self, item: AgentMessageItem, from_replay: bool) { - let mut message = String::new(); - for content in &item.content { - match content { - AgentMessageContent::Text { text } => message.push_str(text), - } - } - let parsed = parse_assistant_markdown(&message); - self.finalize_completed_assistant_message( - (!parsed.visible_markdown.is_empty()).then_some(parsed.visible_markdown.as_str()), - ); - if matches!(item.phase, Some(MessagePhase::FinalAnswer) | None) - && !parsed.visible_markdown.is_empty() - { - self.record_agent_markdown(&parsed.visible_markdown); - } - if !from_replay - && let Some(cwd) = parsed.last_created_branch_cwd() - && let Some(thread_id) = self.thread_id - && let Some(runner) = self.workspace_command_runner.clone() - { - let cwd = PathBuf::from(cwd); - let tx = self.app_event_tx.clone(); - tokio::spawn(async move { - if let Some(branch) = - crate::branch_summary::current_branch_name(runner.as_ref(), &cwd).await - { - tx.send(AppEvent::SyncThreadGitBranch { thread_id, branch }); - } - }); - } - self.status_state.pending_status_indicator_restore = match item.phase { - // Models that don't support preambles only output AgentMessageItems on turn completion. - Some(MessagePhase::FinalAnswer) | None => !self.input_queue.pending_steers.is_empty(), - Some(MessagePhase::Commentary) => true, - }; - self.maybe_restore_status_indicator_after_stream_idle(); - } - - /// Periodic tick for stream commits. In smooth mode this preserves one-line pacing, while - /// catch-up mode drains larger batches to reduce queue lag. - pub(crate) fn on_commit_tick(&mut self) { - self.run_commit_tick(); - } - - /// Runs a regular periodic commit tick. - fn run_commit_tick(&mut self) { - self.run_commit_tick_with_scope(CommitTickScope::AnyMode); - } - - /// Runs an opportunistic commit tick only if catch-up mode is active. - fn run_catch_up_commit_tick(&mut self) { - self.run_commit_tick_with_scope(CommitTickScope::CatchUpOnly); - } - - /// Runs a commit tick for the current stream queue snapshot. - /// - /// `scope` controls whether this call may commit in smooth mode or only when catch-up - /// is currently active. While lines are actively streaming we hide the status row to avoid - /// duplicate "in progress" affordances. Restoration is gated separately so we only re-show - /// the row after commentary completion once stream queues are idle. - fn run_commit_tick_with_scope(&mut self, scope: CommitTickScope) { - let now = Instant::now(); - let outcome = run_commit_tick( - &mut self.adaptive_chunking, - self.stream_controller.as_mut(), - self.plan_stream_controller.as_mut(), - scope, - now, - ); - for cell in outcome.cells { - self.bottom_pane.hide_status_indicator(); - self.add_boxed_history(cell); - } - self.sync_active_stream_tail(); - - if outcome.has_controller && outcome.all_idle { - self.maybe_restore_status_indicator_after_stream_idle(); - self.app_event_tx.send(AppEvent::StopCommitAnimation); - } - - if self.turn_lifecycle.agent_turn_running { - self.refresh_runtime_metrics(); - } - } - - fn flush_interrupt_queue(&mut self) { - let mut mgr = std::mem::take(&mut self.interrupts); - mgr.flush_all(self); - self.interrupts = mgr; - } - - #[inline] - fn defer_or_handle( - &mut self, - push: impl FnOnce(&mut InterruptManager), - handle: impl FnOnce(&mut Self), - ) { - // Preserve deterministic FIFO across queued interrupts: once anything - // is queued due to an active write cycle, continue queueing until the - // queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin). - if self.stream_controller.is_some() || !self.interrupts.is_empty() { - push(&mut self.interrupts); - } else { - handle(self); - } - } - - fn handle_stream_finished(&mut self) { - if self.task_complete_pending { - self.bottom_pane.hide_status_indicator(); - self.task_complete_pending = false; - } - // A completed stream indicates non-exec content was just inserted. - self.flush_interrupt_queue(); - } - - #[inline] - fn handle_streaming_delta(&mut self, delta: String) { - if self.stream_controller.is_none() { - // Before starting an agent stream, flush any active exec cell group. - self.flush_unified_exec_wait_streak(); - self.flush_active_cell(); - // If the previous turn inserted non-stream history (exec output, patch status, MCP - // calls), render a separator before starting the next streamed assistant message. - if self.transcript.needs_final_message_separator && self.transcript.had_work_activity { - self.add_to_history(history_cell::FinalMessageSeparator::new( - /*elapsed_seconds*/ None, /*runtime_metrics*/ None, - )); - self.transcript.needs_final_message_separator = false; - } else if self.transcript.needs_final_message_separator { - // Reset the flag even if we don't show separator (no work was done) - self.transcript.needs_final_message_separator = false; - } - self.stream_controller = Some(StreamController::new( - self.current_stream_width(/*reserved_cols*/ 2), - &self.config.cwd, - self.history_render_mode(), - )); - } - if let Some(controller) = self.stream_controller.as_mut() - && controller.push(&delta) - { - self.app_event_tx.send(AppEvent::StartCommitAnimation); - self.run_catch_up_commit_tick(); - } - self.sync_active_stream_tail(); - self.request_redraw(); - } - - /// Finalizes an exec call while preserving the active exec cell grouping contract. - /// - /// Exec begin/end events usually pair through `running_commands`, but unified exec can emit an - /// end event for a call that was never materialized as the current active `ExecCell` (for - /// example, when another exploring group is still active). In that case we render the end as a - /// standalone history entry instead of replacing or flushing the unrelated active exploring - /// cell. If this method treated every unknown end as "complete the active cell", the UI could - /// merge unrelated commands and hide still-running exploring work. - pub(crate) fn handle_command_execution_completed_now(&mut self, item: ThreadItem) { - enum ExecEndTarget { - // Normal case: the active exec cell already tracks this call id. - ActiveTracked, - // We have an active exec group, but it does not contain this call id. Render the end - // as a standalone finalized history cell so the active group remains intact. - OrphanHistoryWhileActiveExec, - // No active exec cell can safely own this end; build a new cell from the end payload. - NewCell, - } - - let ThreadItem::CommandExecution { - id, - command, - process_id: _, - source, - command_actions, - aggregated_output, - exit_code, - duration_ms, - .. - } = item - else { - return; - }; - let event_command = split_command_string(&command); - let event_parsed = command_actions - .into_iter() - .map(codex_app_server_protocol::CommandAction::into_core) - .collect(); - let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); - let exit_code = exit_code.unwrap_or_default(); - let aggregated_output = aggregated_output.unwrap_or_default(); - - let running = self.running_commands.remove(&id); - if self.suppressed_exec_calls.remove(&id) { - return; - } - let (command, parsed, source) = match running { - Some(rc) => (rc.command, rc.parsed_cmd, rc.source), - None => (event_command, event_parsed, source), - }; - let parsed = self.annotate_skill_reads_in_parsed_cmd(parsed); - let is_unified_exec_interaction = - matches!(source, ExecCommandSource::UnifiedExecInteraction); - let is_user_shell = source == ExecCommandSource::UserShell; - let end_target = match self.transcript.active_cell.as_ref() { - Some(cell) => match cell.as_any().downcast_ref::() { - Some(exec_cell) if exec_cell.iter_calls().any(|call| call.call_id == id) => { - ExecEndTarget::ActiveTracked - } - Some(exec_cell) if exec_cell.is_active() => { - ExecEndTarget::OrphanHistoryWhileActiveExec - } - Some(_) | None => ExecEndTarget::NewCell, - }, - None => ExecEndTarget::NewCell, - }; - - // Unified exec interaction rows intentionally hide command output text in the exec cell and - // instead render the interaction-specific content elsewhere in the UI. - let output = if is_unified_exec_interaction { - CommandOutput { - exit_code, - formatted_output: String::new(), - aggregated_output: String::new(), - } - } else { - CommandOutput { - exit_code, - formatted_output: aggregated_output.clone(), - aggregated_output, - } - }; - - match end_target { - ExecEndTarget::ActiveTracked => { - if let Some(cell) = self - .transcript - .active_cell - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - { - let completed = cell.complete_call(&id, output, duration); - debug_assert!(completed, "active exec cell should contain {id}"); - if cell.should_flush() { - self.flush_active_cell(); - } else { - self.bump_active_cell_revision(); - self.request_redraw(); - } - } - } - ExecEndTarget::OrphanHistoryWhileActiveExec => { - let mut orphan = new_active_exec_command( - id.clone(), - command, - parsed, - source, - /*interaction_input*/ None, - self.config.animations, - ); - let completed = orphan.complete_call(&id, output, duration); - debug_assert!(completed, "new orphan exec cell should contain {id}"); - self.transcript.needs_final_message_separator = true; - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(orphan))); - self.request_redraw(); - } - ExecEndTarget::NewCell => { - self.flush_active_cell(); - let mut cell = new_active_exec_command( - id.clone(), - command, - parsed, - source, - /*interaction_input*/ None, - self.config.animations, - ); - let completed = cell.complete_call(&id, output, duration); - debug_assert!(completed, "new exec cell should contain {id}"); - if cell.should_flush() { - self.add_to_history(cell); - } else { - self.transcript.active_cell = Some(Box::new(cell)); - self.bump_active_cell_revision(); - self.request_redraw(); - } - } - } - // Mark that actual work was done (command executed) - self.transcript.had_work_activity = true; - if is_user_shell { - self.maybe_send_next_queued_input(); - } - } - - pub(crate) fn handle_file_change_completed_now(&mut self, item: ThreadItem) { - let ThreadItem::FileChange { status, .. } = item else { - return; - }; - // If the patch was successful, just let the "Edited" block stand. - // Otherwise, add a failure block. - if matches!(status, codex_app_server_protocol::PatchApplyStatus::Failed) { - self.add_to_history(history_cell::new_patch_apply_failure(String::new())); - } - // Mark that actual work was done (patch applied) - self.transcript.had_work_activity = true; - } - - pub(crate) fn handle_exec_approval_now(&mut self, ev: ExecApprovalRequestEvent) { - self.flush_answer_stream_with_separator(); - let command = shlex::try_join(ev.command.iter().map(String::as_str)) - .unwrap_or_else(|_| ev.command.join(" ")); - self.notify(Notification::ExecApprovalRequested { command }); - - let available_decisions = ev.effective_available_decisions(); - let request = ApprovalRequest::Exec { - thread_id: self.thread_id.unwrap_or_default(), - thread_label: None, - id: ev.effective_approval_id(), - command: ev.command, - reason: ev.reason, - available_decisions, - network_approval_context: ev.network_approval_context, - additional_permissions: ev.additional_permissions, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - } - - pub(crate) fn handle_apply_patch_approval_now(&mut self, ev: ApplyPatchApprovalRequestEvent) { - self.flush_answer_stream_with_separator(); - - let request = ApprovalRequest::ApplyPatch { - thread_id: self.thread_id.unwrap_or_default(), - thread_label: None, - id: ev.call_id, - reason: ev.reason, - changes: ev.changes.clone(), - cwd: self.config.cwd.clone(), - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - self.notify(Notification::EditApprovalRequested { - cwd: self.config.cwd.to_path_buf(), - changes: ev.changes.keys().cloned().collect(), - }); - } - - pub(crate) fn handle_elicitation_request_now( - &mut self, - request_id: AppServerRequestId, - params: McpServerElicitationRequestParams, - ) { - self.flush_answer_stream_with_separator(); - - self.notify(Notification::ElicitationRequested { - server_name: params.server_name.clone(), - }); - - let thread_id = self.thread_id.unwrap_or_default(); - if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request( - thread_id, - ¶ms.server_name, - request_id.clone(), - ¶ms.request, - ) { - self.open_app_link_view(params); - } else if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( - thread_id, - request_id.clone(), - params.clone(), - ) { - self.bottom_pane - .push_mcp_server_elicitation_request(request); - } else { - match params.request { - McpServerElicitationRequest::Form { message, .. } => { - let request = ApprovalRequest::McpElicitation { - thread_id, - thread_label: None, - server_name: params.server_name, - request_id, - message, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - } - McpServerElicitationRequest::Url { .. } => { - self.app_event_tx.resolve_elicitation( - thread_id, - params.server_name, - request_id, - codex_app_server_protocol::McpServerElicitationAction::Decline, - /*content*/ None, - /*meta*/ None, - ); - } - } - } - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - } - - pub(crate) fn push_approval_request(&mut self, request: ApprovalRequest) { - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - } - - pub(crate) fn push_mcp_server_elicitation_request( - &mut self, - request: McpServerElicitationFormRequest, - ) { - self.bottom_pane - .push_mcp_server_elicitation_request(request); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - } - - pub(crate) fn handle_request_user_input_now(&mut self, ev: ToolRequestUserInputParams) { - self.flush_answer_stream_with_separator(); - let question_count = ev.questions.len(); - let summary = Notification::user_input_request_summary(&ev.questions); - let title = match (question_count, summary.as_deref()) { - (1, Some(summary)) => summary.to_string(), - (1, None) => "Question requested".to_string(), - (count, _) => format!("{count} questions requested"), - }; - self.notify(Notification::PlanModePrompt { title }); - self.bottom_pane.push_user_input_request(ev); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - } - - pub(crate) fn handle_request_permissions_now(&mut self, ev: RequestPermissionsEvent) { - self.flush_answer_stream_with_separator(); - let request = ApprovalRequest::Permissions { - thread_id: self.thread_id.unwrap_or_default(), - thread_label: None, - call_id: ev.call_id, - reason: ev.reason, - permissions: ev.permissions, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - } - - pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) { - let ThreadItem::CommandExecution { - id, - command, - source, - command_actions, - .. - } = item - else { - return; - }; - let (command, parsed_cmd) = - command_execution_command_and_parsed(&command, &command_actions); - // Ensure the status indicator is visible while the command runs. - self.bottom_pane.ensure_status_indicator(); - let parsed_cmd = self.annotate_skill_reads_in_parsed_cmd(parsed_cmd); - self.running_commands.insert( - id.clone(), - RunningCommand { - command: command.clone(), - parsed_cmd: parsed_cmd.clone(), - source, - }, - ); - let is_wait_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); - let command_display = command.join(" "); - let should_suppress_unified_wait = is_wait_interaction - && self - .last_unified_wait - .as_ref() - .is_some_and(|wait| wait.is_duplicate(&command_display)); - if is_wait_interaction { - self.last_unified_wait = Some(UnifiedExecWaitState::new(command_display)); - } else { - self.last_unified_wait = None; - } - if should_suppress_unified_wait { - self.suppressed_exec_calls.insert(id); - return; - } - if let Some(cell) = self - .transcript - .active_cell - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - && let Some(new_exec) = cell.with_added_call( - id.clone(), - command.clone(), - parsed_cmd.clone(), - source, - /*interaction_input*/ None, - ) - { - *cell = new_exec; - self.bump_active_cell_revision(); - } else { - self.flush_active_cell(); - - self.transcript.active_cell = Some(Box::new(new_active_exec_command( - id, - command, - parsed_cmd, - source, - /*interaction_input*/ None, - self.config.animations, - ))); - self.bump_active_cell_revision(); - } - - self.request_redraw(); - } - - pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItem) { - let ThreadItem::McpToolCall { - id, - server, - tool, - arguments, - .. - } = item - else { - return; - }; - self.flush_answer_stream_with_separator(); - self.flush_active_cell(); - self.transcript.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( - id, - McpInvocation { - server, - tool, - arguments: Some(arguments), - }, - self.config.animations, - ))); - self.bump_active_cell_revision(); - self.request_redraw(); - } - - pub(crate) fn handle_mcp_tool_call_completed_now(&mut self, item: ThreadItem) { - self.flush_answer_stream_with_separator(); - - let ThreadItem::McpToolCall { - id, - server, - tool, - arguments, - result, - error, - duration_ms, - .. - } = item - else { - return; - }; - let invocation = McpInvocation { - server, - tool, - arguments: Some(arguments), - }; - let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); - let result = match (result, error) { - (_, Some(error)) => Err(error.message), - (Some(result), None) => { - let result = *result; - Ok(codex_protocol::mcp::CallToolResult { - content: result.content, - structured_content: result.structured_content, - is_error: Some(false), - meta: None, - }) - } - (None, None) => Err("MCP tool call completed without a result".to_string()), - }; - - let extra_cell = match self - .transcript - .active_cell - .as_mut() - .and_then(|cell| cell.as_any_mut().downcast_mut::()) - { - Some(cell) if cell.call_id() == id => cell.complete(duration, result), - _ => { - self.flush_active_cell(); - let mut cell = - history_cell::new_active_mcp_tool_call(id, invocation, self.config.animations); - let extra_cell = cell.complete(duration, result); - self.transcript.active_cell = Some(Box::new(cell)); - extra_cell - } - }; - - self.flush_active_cell(); - if let Some(extra) = extra_cell { - self.add_boxed_history(extra); - } - // Mark that actual work was done (MCP tool call) - self.transcript.had_work_activity = true; - } - - pub(crate) fn handle_queued_item_started_now(&mut self, item: ThreadItem) { - match item { - item @ ThreadItem::CommandExecution { .. } => { - self.handle_command_execution_started_now(item); - } - item @ ThreadItem::McpToolCall { .. } => { - self.handle_mcp_tool_call_started_now(item); - } - _ => {} - } - } - - pub(crate) fn handle_queued_item_completed_now(&mut self, item: ThreadItem) { - match item { - item @ ThreadItem::CommandExecution { .. } => { - self.handle_command_execution_completed_now(item); - } - item @ ThreadItem::FileChange { .. } => self.handle_file_change_completed_now(item), - item @ ThreadItem::McpToolCall { .. } => self.handle_mcp_tool_call_completed_now(item), - _ => {} - } - } - pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self { Self::new_with_op_target(common, CodexOpTarget::AppEvent) } @@ -5162,84 +2024,8 @@ impl ChatWidget { } _ => { let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); - match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted { - text, - text_elements, - } => { - let local_images = self - .bottom_pane - .take_recent_submission_images_with_placeholders(); - let remote_image_urls = self.take_remote_image_urls(); - let user_message = UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings: self - .bottom_pane - .take_recent_submission_mention_bindings(), - }; - if user_message.text.is_empty() - && user_message.local_images.is_empty() - && user_message.remote_image_urls.is_empty() - { - return; - } - let should_submit_now = - self.is_session_configured() && !self.is_plan_streaming_in_tui(); - if should_submit_now { - if self.only_user_shell_commands_running() - && !user_message.text.starts_with('!') - { - self.queue_user_message(user_message); - return; - } - // Submitted is emitted when user submits. - // Reset any reasoning header only when we are actually submitting a turn. - self.reasoning_buffer.clear(); - self.full_reasoning_buffer.clear(); - self.set_status_header(String::from("Working")); - self.submit_user_message(user_message); - } else { - self.queue_user_message(user_message); - } - } - InputResult::Queued { - text, - text_elements, - action, - } => { - let local_images = self - .bottom_pane - .take_recent_submission_images_with_placeholders(); - let remote_image_urls = self.take_remote_image_urls(); - let user_message = UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings: self - .bottom_pane - .take_recent_submission_mention_bindings(), - }; - self.queue_user_message_with_options(user_message, action); - } - InputResult::Command(cmd) => { - self.handle_slash_command_dispatch(cmd); - } - InputResult::ServiceTierCommand(command) => { - self.handle_service_tier_command_dispatch(command); - } - InputResult::CommandWithArgs(cmd, args, text_elements) => { - self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); - } - InputResult::None => {} - } - if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() { - self.maybe_send_next_queued_input(); - } - self.refresh_plan_mode_nudge(); + let input_result = self.bottom_pane.handle_key_event(key_event); + self.handle_composer_input_result(input_result, had_modal_or_popup); } } } @@ -5453,814 +2239,6 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } - fn active_cell_is_stream_tail(&self) -> bool { - self.transcript.active_cell.as_ref().is_some_and(|cell| { - cell.as_any().is::() - || cell.as_any().is::() - }) - } - - fn has_active_stream_tail(&self) -> bool { - (self.stream_controller.is_some() || self.plan_stream_controller.is_some()) - && self.active_cell_is_stream_tail() - } - - fn sync_active_stream_tail(&mut self) { - if let Some(controller) = self.stream_controller.as_ref() { - let tail_lines = controller.current_tail_lines(); - if tail_lines.is_empty() { - self.clear_active_stream_tail(); - return; - } - - self.bottom_pane.hide_status_indicator(); - self.transcript.active_cell = - Some(Box::new(history_cell::StreamingAgentTailCell::new( - tail_lines, - controller.tail_starts_stream(), - ))); - self.bump_active_cell_revision(); - return; - } - - if let Some(controller) = self.plan_stream_controller.as_ref() { - let tail_lines = controller.current_tail_display_lines(); - if tail_lines.is_empty() { - self.clear_active_stream_tail(); - return; - } - - self.bottom_pane.hide_status_indicator(); - self.transcript.active_cell = Some(Box::new(history_cell::StreamingPlanTailCell::new( - tail_lines, - !controller.tail_starts_stream(), - ))); - self.bump_active_cell_revision(); - return; - } - - self.clear_active_stream_tail(); - } - - fn clear_active_stream_tail(&mut self) { - if self.active_cell_is_stream_tail() { - self.transcript.active_cell = None; - self.bump_active_cell_revision(); - } - } - - fn queue_user_message(&mut self, user_message: UserMessage) { - self.queue_user_message_with_options(user_message, QueuedInputAction::Plain); - } - - fn queue_user_message_with_options( - &mut self, - user_message: UserMessage, - action: QueuedInputAction, - ) { - if !self.is_session_configured() || self.is_user_turn_pending_or_running() { - self.input_queue - .queued_user_messages - .push_back(QueuedUserMessage::new(user_message, action)); - self.input_queue - .queued_user_message_history_records - .push_back(UserMessageHistoryRecord::UserMessageText); - self.refresh_pending_input_preview(); - } else { - self.submit_user_message(user_message); - } - } - - fn submit_shell_command(&mut self, command: &str) -> QueueDrain { - let cmd = command.trim(); - if cmd.is_empty() { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event( - USER_SHELL_COMMAND_HELP_TITLE.to_string(), - Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), - ), - ))); - QueueDrain::Continue - } else { - self.submit_op(AppCommand::run_user_shell_command(cmd.to_string())); - QueueDrain::Stop - } - } - - fn submit_shell_command_with_history( - &mut self, - command: &str, - history_text: &str, - ) -> QueueDrain { - let drain = self.submit_shell_command(command); - if drain == QueueDrain::Stop { - self.append_message_history_entry(history_text.to_string()); - } - drain - } - - fn submit_queued_shell_prompt(&mut self, user_message: UserMessage) -> QueueDrain { - match user_message.text.strip_prefix('!') { - Some(command) => { - let history_text = user_message.text.clone(); - self.submit_shell_command_with_history(command, &history_text) - } - None => { - self.submit_user_message(user_message); - QueueDrain::Stop - } - } - } - - fn submit_user_message(&mut self, user_message: UserMessage) { - let _accepted = self.submit_user_message_with_history_record( - user_message, - UserMessageHistoryRecord::UserMessageText, - ); - } - - fn submit_user_message_with_history_record( - &mut self, - user_message: UserMessage, - history_record: UserMessageHistoryRecord, - ) -> bool { - self.submit_user_message_with_history_and_shell_escape_policy( - user_message, - history_record, - ShellEscapePolicy::Allow, - ) - .0 - } - - fn submit_user_message_with_shell_escape_policy( - &mut self, - user_message: UserMessage, - shell_escape_policy: ShellEscapePolicy, - ) -> Option { - self.submit_user_message_with_history_and_shell_escape_policy( - user_message, - UserMessageHistoryRecord::UserMessageText, - shell_escape_policy, - ) - .1 - } - - fn submit_user_message_with_history_and_shell_escape_policy( - &mut self, - user_message: UserMessage, - history_record: UserMessageHistoryRecord, - shell_escape_policy: ShellEscapePolicy, - ) -> (bool, Option) { - if !self.is_session_configured() { - tracing::warn!("cannot submit user message before session is configured; queueing"); - self.input_queue - .queued_user_messages - .push_front(QueuedUserMessage::from(user_message)); - self.input_queue - .queued_user_message_history_records - .push_front(history_record); - self.refresh_pending_input_preview(); - return (true, None); - } - if user_message.text.is_empty() - && user_message.local_images.is_empty() - && user_message.remote_image_urls.is_empty() - { - return (false, None); - } - if (!user_message.local_images.is_empty() || !user_message.remote_image_urls.is_empty()) - && !self.current_model_supports_images() - { - let UserMessage { - text, - text_elements, - local_images, - mention_bindings, - remote_image_urls, - } = user_message_for_restore(user_message, &history_record); - self.restore_blocked_image_submission( - text, - text_elements, - local_images, - mention_bindings, - remote_image_urls, - ); - return (false, None); - } - let UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings, - } = user_message; - - let render_in_history = !self.turn_lifecycle.agent_turn_running; - let mut items: Vec = Vec::new(); - - // Special-case: "!cmd" executes a local shell command instead of sending to the model. - if shell_escape_policy == ShellEscapePolicy::Allow - && let Some(stripped) = text.strip_prefix('!') - { - let app_command = match self.submit_shell_command_with_history(stripped, &text) { - QueueDrain::Continue => None, - QueueDrain::Stop => Some(AppCommand::run_user_shell_command( - stripped.trim().to_string(), - )), - }; - return (app_command.is_some(), app_command); - } - - for image_url in &remote_image_urls { - items.push(UserInput::Image { - url: image_url.clone(), - }); - } - - for image in &local_images { - items.push(UserInput::LocalImage { - path: image.path.clone(), - }); - } - - if !text.is_empty() { - items.push(UserInput::Text { - text: text.clone(), - text_elements: app_server_text_elements(&text_elements), - }); - } - - let mentions = collect_tool_mentions(&text, &HashMap::new()); - let bound_names: HashSet = mention_bindings - .iter() - .map(|binding| binding.mention.clone()) - .collect(); - let mut skill_names_lower: HashSet = HashSet::new(); - let mut selected_skill_paths: HashSet = HashSet::new(); - let mut selected_plugin_ids: HashSet = HashSet::new(); - - if let Some(skills) = self.bottom_pane.skills() { - skill_names_lower = skills - .iter() - .map(|skill| skill.name.to_ascii_lowercase()) - .collect(); - - for binding in &mention_bindings { - let path = binding - .path - .strip_prefix("skill://") - .unwrap_or(binding.path.as_str()); - let path = Path::new(path); - if let Some(skill) = skills - .iter() - .find(|skill| skill.path_to_skills_md.as_path() == path) - && selected_skill_paths.insert(skill.path_to_skills_md.clone()) - { - items.push(UserInput::Skill { - name: skill.name.clone(), - path: skill.path_to_skills_md.to_path_buf(), - }); - } - } - - let skill_mentions = find_skill_mentions_with_tool_mentions(&mentions, skills); - for skill in skill_mentions { - if bound_names.contains(skill.name.as_str()) - || !selected_skill_paths.insert(skill.path_to_skills_md.clone()) - { - continue; - } - items.push(UserInput::Skill { - name: skill.name.clone(), - path: skill.path_to_skills_md.to_path_buf(), - }); - } - } - - if let Some(plugins) = self.plugins_for_mentions() { - for binding in &mention_bindings { - let Some(plugin_config_name) = binding - .path - .strip_prefix("plugin://") - .filter(|id| !id.is_empty()) - else { - continue; - }; - if !selected_plugin_ids.insert(plugin_config_name.to_string()) { - continue; - } - if let Some(plugin) = plugins - .iter() - .find(|plugin| plugin.config_name == plugin_config_name) - { - items.push(UserInput::Mention { - name: plugin.display_name.clone(), - path: binding.path.clone(), - }); - } - } - } - - let mut selected_app_ids: HashSet = HashSet::new(); - if let Some(apps) = self.connectors_for_mentions() { - for binding in &mention_bindings { - let Some(app_id) = binding - .path - .strip_prefix("app://") - .filter(|id| !id.is_empty()) - else { - continue; - }; - if !selected_app_ids.insert(app_id.to_string()) { - continue; - } - if let Some(app) = apps.iter().find(|app| app.id == app_id && app.is_enabled) { - items.push(UserInput::Mention { - name: app.name.clone(), - path: binding.path.clone(), - }); - } - } - - let app_mentions = find_app_mentions(&mentions, apps, &skill_names_lower); - for app in app_mentions { - let slug = codex_connectors::metadata::connector_mention_slug(&app); - if bound_names.contains(&slug) || !selected_app_ids.insert(app.id.clone()) { - continue; - } - let app_id = app.id.as_str(); - items.push(UserInput::Mention { - name: app.name.clone(), - path: format!("app://{app_id}"), - }); - } - } - - let effective_mode = self.effective_collaboration_mode(); - if effective_mode.model().trim().is_empty() { - self.add_error_message( - "Thread model is unavailable. Wait for the thread to finish syncing or choose a model before sending input.".to_string(), - ); - self.restore_user_message_to_composer(user_message_for_restore( - UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings, - }, - &history_record, - )); - return (false, None); - } - - self.maybe_apply_ide_context(&mut items); - - let collaboration_mode = if self.collaboration_modes_enabled() { - self.active_collaboration_mask - .as_ref() - .map(|_| effective_mode.clone()) - } else { - None - }; - let pending_steer = (!render_in_history).then(|| PendingSteer { - user_message: UserMessage { - text: text.clone(), - local_images: local_images.clone(), - remote_image_urls: remote_image_urls.clone(), - text_elements: text_elements.clone(), - mention_bindings: mention_bindings.clone(), - }, - history_record: history_record.clone(), - compare_key: Self::pending_steer_compare_key_from_items(&items), - }); - let personality = self - .config - .personality - .filter(|_| self.config.features.enabled(Feature::Personality)) - .filter(|_| self.current_model_supports_personality()); - let service_tier = match self.config.service_tier.clone() { - Some(service_tier) => Some(Some(service_tier)), - None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), - None => None, - }; - let permission_profile = self.config.permissions.permission_profile(); - let op = AppCommand::user_turn( - items, - self.config.cwd.to_path_buf(), - AskForApproval::from(self.config.permissions.approval_policy.value()), - permission_profile, - effective_mode.model().to_string(), - effective_mode.reasoning_effort(), - /*summary*/ None, - service_tier, - /*final_output_json_schema*/ None, - collaboration_mode, - personality, - ); - - if !self.submit_op(op.clone()) { - return (false, None); - } - if render_in_history { - self.input_queue.user_turn_pending_start = true; - } - - // Persist the submitted text to cross-session message history. Mentions are encoded into - // placeholder syntax so recall can reconstruct the mention bindings in a future session. - let encoded_mentions = mention_bindings - .iter() - .map(|binding| LinkedMention { - mention: binding.mention.clone(), - path: binding.path.clone(), - }) - .collect::>(); - let history_text = match &history_record { - UserMessageHistoryRecord::UserMessageText if !text.is_empty() => { - Some(encode_history_mentions(&text, &encoded_mentions)) - } - UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { - Some(encode_history_mentions(&history.text, &encoded_mentions)) - } - UserMessageHistoryRecord::UserMessageText | UserMessageHistoryRecord::Override(_) => { - None - } - }; - if let Some(history_text) = history_text { - self.append_message_history_entry(history_text); - } - - if let Some(pending_steer) = pending_steer { - self.input_queue.pending_steers.push_back(pending_steer); - self.transcript.saw_plan_item_this_turn = false; - self.refresh_pending_input_preview(); - } - - // Show replayable user content in conversation history. - let display_user_message = render_in_history.then(|| { - user_message_display_for_history( - UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings, - }, - &history_record, - ) - }); - if let Some(display) = display_user_message { - self.on_user_message_display(display); - } - - self.transcript.needs_final_message_separator = false; - (true, Some(op)) - } - - /// Restore the blocked submission draft without losing mention resolution state. - /// - /// The blocked-image path intentionally keeps the draft in the composer so - /// users can remove attachments and retry. We must restore - /// mention bindings alongside visible text; restoring only `$name` tokens - /// makes the draft look correct while degrading mention resolution to - /// name-only heuristics on retry. - fn restore_blocked_image_submission( - &mut self, - text: String, - text_elements: Vec, - local_images: Vec, - mention_bindings: Vec, - remote_image_urls: Vec, - ) { - // Preserve the user's composed payload so they can retry after changing models. - let local_image_paths = local_images.iter().map(|img| img.path.clone()).collect(); - self.set_remote_image_urls(remote_image_urls); - self.bottom_pane.set_composer_text_with_mention_bindings( - text, - text_elements, - local_image_paths, - mention_bindings, - ); - self.add_to_history(history_cell::new_warning_event( - self.image_inputs_not_supported_message(), - )); - self.request_redraw(); - } - - /// Replay a subset of initial events into the UI to seed the transcript when - /// resuming an existing session. This approximates the live event flow and - /// is intentionally conservative: only safe-to-replay items are rendered to - /// avoid triggering side effects. Event ids are passed as `None` to - /// distinguish replayed events from live ones. - pub(crate) fn replay_thread_turns(&mut self, turns: Vec, replay_kind: ReplayKind) { - for turn in turns { - let Turn { - id: turn_id, - items_view: _, - items, - status, - error, - started_at, - completed_at, - duration_ms, - } = turn; - if matches!(status, TurnStatus::InProgress) { - self.last_non_retry_error = None; - self.on_task_started(); - } - for item in items { - self.replay_thread_item(item, turn_id.clone(), replay_kind); - } - if matches!( - status, - TurnStatus::Completed | TurnStatus::Interrupted | TurnStatus::Failed - ) { - self.handle_turn_completed_notification( - TurnCompletedNotification { - thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(), - turn: Turn { - id: turn_id, - items_view: codex_app_server_protocol::TurnItemsView::NotLoaded, - items: Vec::new(), - status, - error, - started_at, - completed_at, - duration_ms, - }, - }, - Some(replay_kind), - ); - } - } - } - - pub(crate) fn replay_thread_item( - &mut self, - item: ThreadItem, - turn_id: String, - replay_kind: ReplayKind, - ) { - self.handle_thread_item(item, turn_id, ThreadItemRenderSource::Replay(replay_kind)); - } - - fn handle_thread_item( - &mut self, - item: ThreadItem, - turn_id: String, - render_source: ThreadItemRenderSource, - ) { - let from_replay = render_source.is_replay(); - let replay_kind = render_source.replay_kind(); - match item { - ThreadItem::UserMessage { content, .. } => { - self.on_committed_user_message(&content, from_replay); - } - ThreadItem::AgentMessage { - id, - text, - phase, - memory_citation, - } => { - self.on_agent_message_item_completed( - AgentMessageItem { - id, - content: vec![AgentMessageContent::Text { text }], - phase, - memory_citation: memory_citation.map(|citation| { - codex_protocol::memory_citation::MemoryCitation { - entries: citation - .entries - .into_iter() - .map(|entry| { - codex_protocol::memory_citation::MemoryCitationEntry { - path: entry.path, - line_start: entry.line_start, - line_end: entry.line_end, - note: entry.note, - } - }) - .collect(), - rollout_ids: citation.thread_ids, - } - }), - }, - from_replay, - ); - } - ThreadItem::Plan { text, .. } => self.on_plan_item_completed(text), - ThreadItem::Reasoning { - summary, content, .. - } => { - if from_replay { - for delta in summary { - self.on_agent_reasoning_delta(delta); - } - if self.config.show_raw_agent_reasoning { - for delta in content { - self.on_agent_reasoning_delta(delta); - } - } - } - self.on_agent_reasoning_final(); - } - item @ ThreadItem::CommandExecution { - status: codex_app_server_protocol::CommandExecutionStatus::InProgress, - .. - } => self.on_command_execution_started(item), - item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item), - ThreadItem::FileChange { - status: codex_app_server_protocol::PatchApplyStatus::InProgress, - .. - } => {} - item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item), - item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item), - ThreadItem::WebSearch { id, query, action } => { - self.on_web_search_begin(id.clone()); - self.on_web_search_end( - id, - query, - action.unwrap_or(codex_app_server_protocol::WebSearchAction::Other), - ); - } - ThreadItem::ImageView { id: _, path } => { - self.on_view_image_tool_call(path); - } - ThreadItem::ImageGeneration { - id, - revised_prompt, - saved_path, - .. - } => { - self.on_image_generation_end(id, revised_prompt, saved_path); - } - ThreadItem::EnteredReviewMode { review, .. } => { - if from_replay { - self.enter_review_mode_with_hint(review, /*from_replay*/ true); - } - } - ThreadItem::ExitedReviewMode { .. } => { - self.exit_review_mode_after_item(); - } - ThreadItem::ContextCompaction { .. } => { - self.add_info_message("Context compacted".to_string(), /*hint*/ None); - } - ThreadItem::HookPrompt { .. } => {} - ThreadItem::CollabAgentToolCall { - id, - tool, - status, - sender_thread_id, - receiver_thread_ids, - prompt, - model, - reasoning_effort, - agents_states, - } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { - id, - tool, - status, - sender_thread_id, - receiver_thread_ids, - prompt, - model, - reasoning_effort, - agents_states, - }), - ThreadItem::DynamicToolCall { .. } => {} - } - - if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) && turn_id.is_empty() { - self.request_redraw(); - } - } - - pub(crate) fn handle_server_request( - &mut self, - request: ServerRequest, - replay_kind: Option, - ) { - let id = request.id().to_string(); - match request { - ServerRequest::CommandExecutionRequestApproval { params, .. } => { - let fallback_cwd = self.config.cwd.clone(); - self.on_exec_approval_request( - id, - exec_approval_request_from_params(params, &fallback_cwd), - ); - } - ServerRequest::FileChangeRequestApproval { params, .. } => { - self.on_apply_patch_approval_request( - id, - patch_approval_request_from_params(params), - ); - } - ServerRequest::McpServerElicitationRequest { request_id, params } => { - self.on_elicitation_request(request_id, params); - } - ServerRequest::PermissionsRequestApproval { params, .. } => { - self.on_request_permissions(request_permissions_from_params(params)); - } - ServerRequest::ToolRequestUserInput { params, .. } => { - self.on_request_user_input(params); - } - ServerRequest::DynamicToolCall { .. } - | ServerRequest::AttestationGenerate { .. } - | ServerRequest::ChatgptAuthTokensRefresh { .. } - | ServerRequest::ApplyPatchApproval { .. } - | ServerRequest::ExecCommandApproval { .. } => { - if replay_kind.is_none() { - self.add_error_message(TUI_STUB_MESSAGE.to_string()); - } - } - } - } - - pub(crate) fn handle_skills_list_response(&mut self, response: SkillsListResponse) { - self.on_list_skills(response); - } - - fn on_patch_apply_output_delta(&mut self, _item_id: String, _delta: String) {} - - fn on_guardian_review_notification( - &mut self, - id: String, - turn_id: String, - started_at_ms: i64, - review: codex_app_server_protocol::GuardianApprovalReview, - completion: Option<(i64, codex_app_server_protocol::AutoReviewDecisionSource)>, - action: GuardianApprovalReviewAction, - ) { - let (completed_at_ms, decision_source) = match completion { - Some((completed_at_ms, decision_source)) => { - (Some(completed_at_ms), Some(decision_source)) - } - None => (None, None), - }; - - self.on_guardian_assessment(GuardianAssessmentEvent { - id, - target_item_id: None, - turn_id, - started_at_ms, - completed_at_ms, - status: match review.status { - codex_app_server_protocol::GuardianApprovalReviewStatus::InProgress => { - GuardianAssessmentStatus::InProgress - } - codex_app_server_protocol::GuardianApprovalReviewStatus::Approved => { - GuardianAssessmentStatus::Approved - } - codex_app_server_protocol::GuardianApprovalReviewStatus::Denied => { - GuardianAssessmentStatus::Denied - } - codex_app_server_protocol::GuardianApprovalReviewStatus::TimedOut => { - GuardianAssessmentStatus::TimedOut - } - codex_app_server_protocol::GuardianApprovalReviewStatus::Aborted => { - GuardianAssessmentStatus::Aborted - } - }, - risk_level: review.risk_level.map(|risk_level| match risk_level { - codex_app_server_protocol::GuardianRiskLevel::Low => { - codex_protocol::approvals::GuardianRiskLevel::Low - } - codex_app_server_protocol::GuardianRiskLevel::Medium => { - codex_protocol::approvals::GuardianRiskLevel::Medium - } - codex_app_server_protocol::GuardianRiskLevel::High => { - codex_protocol::approvals::GuardianRiskLevel::High - } - codex_app_server_protocol::GuardianRiskLevel::Critical => { - codex_protocol::approvals::GuardianRiskLevel::Critical - } - }), - user_authorization: review.user_authorization.map(|user_authorization| { - match user_authorization { - codex_app_server_protocol::GuardianUserAuthorization::Unknown => { - codex_protocol::approvals::GuardianUserAuthorization::Unknown - } - codex_app_server_protocol::GuardianUserAuthorization::Low => { - codex_protocol::approvals::GuardianUserAuthorization::Low - } - codex_app_server_protocol::GuardianUserAuthorization::Medium => { - codex_protocol::approvals::GuardianUserAuthorization::Medium - } - codex_app_server_protocol::GuardianUserAuthorization::High => { - codex_protocol::approvals::GuardianUserAuthorization::High - } - } - }), - rationale: review.rationale, - decision_source: decision_source.map(|source| match source { - codex_app_server_protocol::AutoReviewDecisionSource::Agent => { - GuardianAssessmentDecisionSource::Agent - } - }), - action: action.into(), - }); - } - fn enter_review_mode_with_hint(&mut self, hint: String, from_replay: bool) { if self.review.pre_review_token_info.is_none() { self.review.pre_review_token_info = Some(self.token_info.clone()); @@ -6397,71 +2375,6 @@ impl ChatWidget { } } - // If idle and there are queued inputs, submit exactly one to start the next turn. - pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool { - if self.input_queue.suppress_queue_autosend { - return false; - } - if self.is_user_turn_pending_or_running() { - return false; - } - let mut submitted_follow_up = false; - while !self.is_user_turn_pending_or_running() { - let Some((queued_message, history_record)) = self.pop_next_queued_user_message() else { - break; - }; - match queued_message.action { - QueuedInputAction::Plain => { - submitted_follow_up = self.submit_user_message_with_history_record( - queued_message.into_user_message(), - history_record, - ); - break; - } - QueuedInputAction::ParseSlash => { - let drain = self.submit_queued_slash_prompt(queued_message.into_user_message()); - if drain == QueueDrain::Stop { - submitted_follow_up = self.is_user_turn_pending_or_running(); - break; - } - } - QueuedInputAction::RunShell => { - let drain = self.submit_queued_shell_prompt(queued_message.into_user_message()); - if drain == QueueDrain::Stop { - submitted_follow_up = self.is_user_turn_pending_or_running(); - break; - } - } - } - } - // Update the list to reflect the remaining queued messages (if any). - self.refresh_pending_input_preview(); - submitted_follow_up - } - - pub(super) fn is_user_turn_pending_or_running(&self) -> bool { - self.input_queue.user_turn_pending_start || self.bottom_pane.is_task_running() - } - - fn only_user_shell_commands_running(&self) -> bool { - self.turn_lifecycle.agent_turn_running - && !self.running_commands.is_empty() - && self - .running_commands - .values() - .all(|command| command.source == ExecCommandSource::UserShell) - } - - /// Rebuild and update the bottom-pane pending-input preview. - fn refresh_pending_input_preview(&mut self) { - let preview = self.input_queue.preview(); - self.bottom_pane.set_pending_input_preview( - preview.queued_messages, - preview.pending_steers, - preview.rejected_steers, - ); - } - pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { self.bottom_pane.set_pending_thread_approvals(threads); } @@ -6585,6 +2498,7 @@ impl ChatWidget { self.config.tui_status_line_use_colors, self.status_surface_preview_data(), self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), ); self.bottom_pane.show_view(Box::new(view)); } @@ -6596,6 +2510,7 @@ impl ChatWidget { Some(configured_terminal_title_items.as_slice()), self.terminal_title_preview_data(), self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), ); self.bottom_pane.show_view(Box::new(view)); } @@ -6919,9 +2834,7 @@ impl ChatWidget { } fn open_workspace_owner_nudge_prompt(&mut self, credit_type: AddCreditsNudgeCreditType) { - if !self.workspace_owner_usage_nudge_enabled() - || self.add_credits_nudge_email_in_flight.is_some() - { + if self.add_credits_nudge_email_in_flight.is_some() { return; } @@ -6969,10 +2882,6 @@ impl ChatWidget { &mut self, credit_type: AddCreditsNudgeCreditType, ) -> bool { - if !self.workspace_owner_usage_nudge_enabled() { - return false; - } - self.add_credits_nudge_email_in_flight = Some(credit_type); true } @@ -6985,9 +2894,6 @@ impl ChatWidget { .add_credits_nudge_email_in_flight .take() .unwrap_or(AddCreditsNudgeCreditType::Credits); - if !self.workspace_owner_usage_nudge_enabled() { - return; - } let message = match (credit_type, result) { (AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => { "Workspace owner notified." @@ -8130,7 +4036,11 @@ impl ChatWidget { }) .collect(); - let view = ExperimentalFeaturesView::new(features, self.app_event_tx.clone()); + let view = ExperimentalFeaturesView::new( + features, + self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), + ); self.bottom_pane.show_view(Box::new(view)); } @@ -9961,40 +5871,6 @@ impl ChatWidget { self.add_info_message(message.to_string(), /*hint*/ None); } - pub(crate) fn submit_user_message_with_mode( - &mut self, - text: String, - mut collaboration_mode: CollaborationModeMask, - ) { - if collaboration_mode.mode == Some(ModeKind::Plan) - && let Some(effort) = self.config.plan_mode_reasoning_effort - { - collaboration_mode.reasoning_effort = Some(Some(effort)); - } - if self.turn_lifecycle.agent_turn_running - && self.active_collaboration_mask.as_ref() != Some(&collaboration_mode) - { - self.add_error_message( - "Cannot switch collaboration mode while a turn is running.".to_string(), - ); - return; - } - self.set_collaboration_mask(collaboration_mode); - let should_queue = self.is_plan_streaming_in_tui(); - let user_message = UserMessage { - text, - local_images: Vec::new(), - remote_image_urls: Vec::new(), - text_elements: Vec::new(), - mention_bindings: Vec::new(), - }; - if should_queue { - self.queue_user_message(user_message); - } else { - self.submit_user_message(user_message); - } - } - /// True when the UI is in the regular composer state with no running task, /// no modal overlay (e.g. approvals or status indicator), and no composer popups. /// In this state Esc-Esc backtracking is enabled. @@ -10036,21 +5912,6 @@ impl ChatWidget { self.bottom_pane.remote_image_urls() } - #[cfg(test)] - pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.input_queue - .rejected_steers_queue - .iter() - .map(|message| message.text.clone()) - .chain( - self.input_queue - .queued_user_messages - .iter() - .map(|message| message.text.clone()), - ) - .collect() - } - #[cfg(test)] pub(crate) fn pending_thread_approvals(&self) -> &[String] { self.bottom_pane.pending_thread_approvals() diff --git a/codex-rs/tui/src/chatwidget/command_lifecycle.rs b/codex-rs/tui/src/chatwidget/command_lifecycle.rs new file mode 100644 index 0000000000..efe7522963 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/command_lifecycle.rs @@ -0,0 +1,454 @@ +//! Command execution lifecycle handlers for `ChatWidget`. +//! +//! This module owns command start/output/completion rendering, including active +//! exec-cell grouping and unified exec wait state. + +use super::*; + +impl ChatWidget { + pub(super) fn flush_unified_exec_wait_streak(&mut self) { + let Some(wait) = self.unified_exec_wait_streak.take() else { + return; + }; + self.transcript.needs_final_message_separator = true; + let cell = history_cell::new_unified_exec_interaction(wait.command_display, String::new()); + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(cell))); + self.restore_reasoning_status_header(); + } + + pub(super) fn on_command_execution_started(&mut self, item: ThreadItem) { + let ThreadItem::CommandExecution { + id, + command, + process_id, + source, + command_actions, + .. + } = &item + else { + return; + }; + let (_command, parsed_cmd) = command_execution_command_and_parsed(command, command_actions); + self.flush_answer_stream_with_separator(); + if is_unified_exec_source(*source) { + if *source == ExecCommandSource::UnifiedExecStartup { + self.track_unified_exec_process_begin(id, process_id.as_deref(), command); + } + if !self.bottom_pane.is_task_running() { + return; + } + // Unified exec may be parsed as Unknown; keep the working indicator visible regardless. + self.bottom_pane.ensure_status_indicator(); + if !is_standard_tool_call(&parsed_cmd) { + return; + } + } + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_started(item), + |s| s.handle_command_execution_started_now(item2), + ); + } + + pub(super) fn on_exec_command_output_delta(&mut self, call_id: &str, delta: &str) { + self.track_unified_exec_output_chunk(call_id, delta.as_bytes()); + if !self.bottom_pane.is_task_running() { + return; + } + + let Some(cell) = self + .transcript + .active_cell + .as_mut() + .and_then(|c| c.as_any_mut().downcast_mut::()) + else { + return; + }; + + if cell.append_output(call_id, delta) { + self.bump_active_cell_revision(); + self.request_redraw(); + } + } + + pub(super) fn on_terminal_interaction(&mut self, process_id: String, stdin: String) { + if !self.bottom_pane.is_task_running() { + return; + } + self.flush_answer_stream_with_separator(); + let command_display = self + .unified_exec_processes + .iter() + .find(|process| process.key == process_id) + .map(|process| process.command_display.clone()); + if stdin.is_empty() { + // Empty stdin means we are polling for background output. + // Surface this in the status indicator (single "waiting" surface) instead of + // the transcript. Keep the header short so the interrupt hint remains visible. + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane + .set_interrupt_hint_visible(/*visible*/ true); + self.status_state.terminal_title_status_kind = + TerminalTitleStatusKind::WaitingForBackgroundTerminal; + self.set_status( + "Waiting for background terminal".to_string(), + command_display.clone(), + StatusDetailsCapitalization::Preserve, + /*details_max_lines*/ 1, + ); + match &mut self.unified_exec_wait_streak { + Some(wait) if wait.process_id == process_id => { + wait.update_command_display(command_display); + } + Some(_) => { + self.flush_unified_exec_wait_streak(); + self.unified_exec_wait_streak = + Some(UnifiedExecWaitStreak::new(process_id, command_display)); + } + None => { + self.unified_exec_wait_streak = + Some(UnifiedExecWaitStreak::new(process_id, command_display)); + } + } + self.request_redraw(); + } else { + if self + .unified_exec_wait_streak + .as_ref() + .is_some_and(|wait| wait.process_id == process_id) + { + self.flush_unified_exec_wait_streak(); + } + self.add_to_history(history_cell::new_unified_exec_interaction( + command_display, + stdin, + )); + } + } + + pub(super) fn on_command_execution_completed(&mut self, item: ThreadItem) { + let ThreadItem::CommandExecution { + id, + process_id, + source, + .. + } = &item + else { + return; + }; + if is_unified_exec_source(*source) { + if let Some(process_id) = process_id.as_deref() + && self + .unified_exec_wait_streak + .as_ref() + .is_some_and(|wait| wait.process_id == process_id) + { + self.flush_unified_exec_wait_streak(); + } + self.track_unified_exec_process_end(id, process_id.as_deref()); + if !self.bottom_pane.is_task_running() { + return; + } + } + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_completed(item), + |s| s.handle_command_execution_completed_now(item2), + ); + } + + pub(super) fn track_unified_exec_process_begin( + &mut self, + call_id: &str, + process_id: Option<&str>, + command: &str, + ) { + let key = process_id.unwrap_or(call_id).to_string(); + let command = split_command_string(command); + let command_display = strip_bash_lc_and_escape(&command); + if let Some(existing) = self + .unified_exec_processes + .iter_mut() + .find(|process| process.key == key) + { + existing.call_id = call_id.to_string(); + existing.command_display = command_display; + existing.recent_chunks.clear(); + } else { + self.unified_exec_processes.push(UnifiedExecProcessSummary { + key, + call_id: call_id.to_string(), + command_display, + recent_chunks: Vec::new(), + }); + } + self.sync_unified_exec_footer(); + } + + pub(super) fn track_unified_exec_process_end( + &mut self, + call_id: &str, + process_id: Option<&str>, + ) { + let key = process_id.unwrap_or(call_id); + let before = self.unified_exec_processes.len(); + self.unified_exec_processes + .retain(|process| process.key != key); + if self.unified_exec_processes.len() != before { + self.sync_unified_exec_footer(); + } + } + + pub(super) fn sync_unified_exec_footer(&mut self) { + let processes = self + .unified_exec_processes + .iter() + .map(|process| process.command_display.clone()) + .collect(); + self.bottom_pane.set_unified_exec_processes(processes); + } + + /// Record recent stdout/stderr lines for the unified exec footer. + pub(super) fn track_unified_exec_output_chunk(&mut self, call_id: &str, chunk: &[u8]) { + let Some(process) = self + .unified_exec_processes + .iter_mut() + .find(|process| process.call_id == call_id) + else { + return; + }; + + let text = String::from_utf8_lossy(chunk); + for line in text + .lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + { + process.recent_chunks.push(line.to_string()); + } + + const MAX_RECENT_CHUNKS: usize = 3; + if process.recent_chunks.len() > MAX_RECENT_CHUNKS { + let drop_count = process.recent_chunks.len() - MAX_RECENT_CHUNKS; + process.recent_chunks.drain(0..drop_count); + } + } + + pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) { + let ThreadItem::CommandExecution { + id, + command, + source, + command_actions, + .. + } = item + else { + return; + }; + let (command, parsed_cmd) = + command_execution_command_and_parsed(&command, &command_actions); + // Ensure the status indicator is visible while the command runs. + self.bottom_pane.ensure_status_indicator(); + let parsed_cmd = self.annotate_skill_reads_in_parsed_cmd(parsed_cmd); + self.running_commands.insert( + id.clone(), + RunningCommand { + command: command.clone(), + parsed_cmd: parsed_cmd.clone(), + source, + }, + ); + let is_wait_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); + let command_display = command.join(" "); + let should_suppress_unified_wait = is_wait_interaction + && self + .last_unified_wait + .as_ref() + .is_some_and(|wait| wait.is_duplicate(&command_display)); + if is_wait_interaction { + self.last_unified_wait = Some(UnifiedExecWaitState::new(command_display)); + } else { + self.last_unified_wait = None; + } + if should_suppress_unified_wait { + self.suppressed_exec_calls.insert(id); + return; + } + if let Some(cell) = self + .transcript + .active_cell + .as_mut() + .and_then(|c| c.as_any_mut().downcast_mut::()) + && let Some(new_exec) = cell.with_added_call( + id.clone(), + command.clone(), + parsed_cmd.clone(), + source, + /*interaction_input*/ None, + ) + { + *cell = new_exec; + self.bump_active_cell_revision(); + } else { + self.flush_active_cell(); + + self.transcript.active_cell = Some(Box::new(new_active_exec_command( + id, + command, + parsed_cmd, + source, + /*interaction_input*/ None, + self.config.animations, + ))); + self.bump_active_cell_revision(); + } + + self.request_redraw(); + } + + /// Finalizes an exec call while preserving the active exec cell grouping contract. + /// + /// Exec begin/end events usually pair through `running_commands`, but unified exec can emit an + /// end event for a call that was never materialized as the current active `ExecCell` (for + /// example, when another exploring group is still active). In that case we render the end as a + /// standalone history entry instead of replacing or flushing the unrelated active exploring + /// cell. If this method treated every unknown end as "complete the active cell", the UI could + /// merge unrelated commands and hide still-running exploring work. + pub(crate) fn handle_command_execution_completed_now(&mut self, item: ThreadItem) { + enum ExecEndTarget { + // Normal case: the active exec cell already tracks this call id. + ActiveTracked, + // We have an active exec group, but it does not contain this call id. Render the end + // as a standalone finalized history cell so the active group remains intact. + OrphanHistoryWhileActiveExec, + // No active exec cell can safely own this end; build a new cell from the end payload. + NewCell, + } + + let ThreadItem::CommandExecution { + id, + command, + process_id: _, + source, + command_actions, + aggregated_output, + exit_code, + duration_ms, + .. + } = item + else { + return; + }; + let event_command = split_command_string(&command); + let event_parsed = command_actions + .into_iter() + .map(codex_app_server_protocol::CommandAction::into_core) + .collect(); + let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); + let exit_code = exit_code.unwrap_or_default(); + let aggregated_output = aggregated_output.unwrap_or_default(); + + let running = self.running_commands.remove(&id); + if self.suppressed_exec_calls.remove(&id) { + return; + } + let (command, parsed, source) = match running { + Some(rc) => (rc.command, rc.parsed_cmd, rc.source), + None => (event_command, event_parsed, source), + }; + let parsed = self.annotate_skill_reads_in_parsed_cmd(parsed); + let is_unified_exec_interaction = + matches!(source, ExecCommandSource::UnifiedExecInteraction); + let is_user_shell = source == ExecCommandSource::UserShell; + let end_target = match self.transcript.active_cell.as_ref() { + Some(cell) => match cell.as_any().downcast_ref::() { + Some(exec_cell) if exec_cell.iter_calls().any(|call| call.call_id == id) => { + ExecEndTarget::ActiveTracked + } + Some(exec_cell) if exec_cell.is_active() => { + ExecEndTarget::OrphanHistoryWhileActiveExec + } + Some(_) | None => ExecEndTarget::NewCell, + }, + None => ExecEndTarget::NewCell, + }; + + // Unified exec interaction rows intentionally hide command output text in the exec cell and + // instead render the interaction-specific content elsewhere in the UI. + let output = if is_unified_exec_interaction { + CommandOutput { + exit_code, + formatted_output: String::new(), + aggregated_output: String::new(), + } + } else { + CommandOutput { + exit_code, + formatted_output: aggregated_output.clone(), + aggregated_output, + } + }; + + match end_target { + ExecEndTarget::ActiveTracked => { + if let Some(cell) = self + .transcript + .active_cell + .as_mut() + .and_then(|c| c.as_any_mut().downcast_mut::()) + { + let completed = cell.complete_call(&id, output, duration); + debug_assert!(completed, "active exec cell should contain {id}"); + if cell.should_flush() { + self.flush_active_cell(); + } else { + self.bump_active_cell_revision(); + self.request_redraw(); + } + } + } + ExecEndTarget::OrphanHistoryWhileActiveExec => { + let mut orphan = new_active_exec_command( + id.clone(), + command, + parsed, + source, + /*interaction_input*/ None, + self.config.animations, + ); + let completed = orphan.complete_call(&id, output, duration); + debug_assert!(completed, "new orphan exec cell should contain {id}"); + self.transcript.needs_final_message_separator = true; + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(orphan))); + self.request_redraw(); + } + ExecEndTarget::NewCell => { + self.flush_active_cell(); + let mut cell = new_active_exec_command( + id.clone(), + command, + parsed, + source, + /*interaction_input*/ None, + self.config.animations, + ); + let completed = cell.complete_call(&id, output, duration); + debug_assert!(completed, "new exec cell should contain {id}"); + if cell.should_flush() { + self.add_to_history(cell); + } else { + self.transcript.active_cell = Some(Box::new(cell)); + self.bump_active_cell_revision(); + self.request_redraw(); + } + } + } + // Mark that actual work was done (command executed) + self.transcript.had_work_activity = true; + if is_user_shell { + self.maybe_send_next_queued_input(); + } + } +} diff --git a/codex-rs/tui/src/chatwidget/exec_state.rs b/codex-rs/tui/src/chatwidget/exec_state.rs new file mode 100644 index 0000000000..c8bb698cef --- /dev/null +++ b/codex-rs/tui/src/chatwidget/exec_state.rs @@ -0,0 +1,83 @@ +//! Unified exec bookkeeping state and helpers for `ChatWidget`. + +use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; +use codex_protocol::parse_command::ParsedCommand; + +use crate::exec_command::split_command_string; + +pub(super) struct RunningCommand { + pub(super) command: Vec, + pub(super) parsed_cmd: Vec, + pub(super) source: ExecCommandSource, +} + +pub(super) struct UnifiedExecProcessSummary { + pub(super) key: String, + pub(super) call_id: String, + pub(super) command_display: String, + pub(super) recent_chunks: Vec, +} + +pub(super) struct UnifiedExecWaitState { + command_display: String, +} + +impl UnifiedExecWaitState { + pub(super) fn new(command_display: String) -> Self { + Self { command_display } + } + + pub(super) fn is_duplicate(&self, command_display: &str) -> bool { + self.command_display == command_display + } +} + +#[derive(Clone, Debug)] +pub(super) struct UnifiedExecWaitStreak { + pub(super) process_id: String, + pub(super) command_display: Option, +} + +impl UnifiedExecWaitStreak { + pub(super) fn new(process_id: String, command_display: Option) -> Self { + Self { + process_id, + command_display: command_display.filter(|display| !display.is_empty()), + } + } + + pub(super) fn update_command_display(&mut self, command_display: Option) { + if self.command_display.is_some() { + return; + } + self.command_display = command_display.filter(|display| !display.is_empty()); + } +} + +pub(super) fn is_unified_exec_source(source: ExecCommandSource) -> bool { + matches!( + source, + ExecCommandSource::UnifiedExecStartup | ExecCommandSource::UnifiedExecInteraction + ) +} + +pub(super) fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool { + !parsed_cmd.is_empty() + && parsed_cmd + .iter() + .all(|parsed| !matches!(parsed, ParsedCommand::Unknown { .. })) +} + +pub(super) fn command_execution_command_and_parsed( + command: &str, + command_actions: &[codex_app_server_protocol::CommandAction], +) -> (Vec, Vec) { + ( + split_command_string(command), + command_actions + .iter() + .cloned() + .map(codex_app_server_protocol::CommandAction::into_core) + .collect(), + ) +} diff --git a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs new file mode 100644 index 0000000000..db3ac314d2 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs @@ -0,0 +1,132 @@ +//! Hook run lifecycle handling for `ChatWidget`. +//! +//! This module keeps active hook cells, hook timers, and hook completion output +//! together. + +use super::*; + +impl ChatWidget { + pub(super) fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) { + self.flush_answer_stream_with_separator(); + self.flush_completed_hook_output(); + match self.active_hook_cell.as_mut() { + Some(cell) => { + cell.start_run(run); + self.bump_active_cell_revision(); + } + None => { + self.active_hook_cell = Some(history_cell::new_active_hook_cell( + run, + self.config.animations, + )); + self.bump_active_cell_revision(); + } + } + self.request_redraw(); + } + + pub(super) fn on_hook_completed( + &mut self, + completed: codex_app_server_protocol::HookRunSummary, + ) { + let completed_existing_run = self + .active_hook_cell + .as_mut() + .map(|cell| cell.complete_run(completed.clone())) + .unwrap_or(false); + if completed_existing_run { + self.bump_active_cell_revision(); + } else { + match self.active_hook_cell.as_mut() { + Some(cell) => { + cell.add_completed_run(completed); + self.bump_active_cell_revision(); + } + None => { + let cell = + history_cell::new_completed_hook_cell(completed, self.config.animations); + if !cell.is_empty() { + self.active_hook_cell = Some(cell); + self.bump_active_cell_revision(); + } + } + } + } + self.flush_completed_hook_output(); + self.finish_active_hook_cell_if_idle(); + self.request_redraw(); + } + + pub(super) fn flush_completed_hook_output(&mut self) { + let Some(completed_cell) = self + .active_hook_cell + .as_mut() + .and_then(HookCell::take_completed_persistent_runs) + else { + return; + }; + let active_cell_is_empty = self + .active_hook_cell + .as_ref() + .is_some_and(HookCell::is_empty); + if active_cell_is_empty { + self.active_hook_cell = None; + } + self.bump_active_cell_revision(); + self.transcript.needs_final_message_separator = true; + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(completed_cell))); + } + + pub(super) fn finish_active_hook_cell_if_idle(&mut self) { + let Some(cell) = self.active_hook_cell.as_ref() else { + return; + }; + if cell.is_empty() { + self.active_hook_cell = None; + self.bump_active_cell_revision(); + return; + } + if cell.should_flush() + && let Some(cell) = self.active_hook_cell.take() + { + self.bump_active_cell_revision(); + self.transcript.needs_final_message_separator = true; + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(cell))); + } + } + + pub(super) fn update_due_hook_visibility(&mut self) { + let Some(cell) = self.active_hook_cell.as_mut() else { + return; + }; + let now = Instant::now(); + if cell.advance_time(now) { + self.bump_active_cell_revision(); + } + self.finish_active_hook_cell_if_idle(); + } + + pub(super) fn schedule_hook_timer_if_needed(&self) { + if self.config.animations + && self + .active_hook_cell + .as_ref() + .is_some_and(HookCell::has_visible_running_run) + { + self.frame_requester + .schedule_frame_in(Duration::from_millis(50)); + } + + let Some(deadline) = self + .active_hook_cell + .as_ref() + .and_then(HookCell::next_timer_deadline) + else { + return; + }; + let delay = deadline.saturating_duration_since(Instant::now()); + self.frame_requester.schedule_frame_in(delay); + } +} diff --git a/codex-rs/tui/src/chatwidget/hooks.rs b/codex-rs/tui/src/chatwidget/hooks.rs index f8c52f74ea..3cc638a8d7 100644 --- a/codex-rs/tui/src/chatwidget/hooks.rs +++ b/codex-rs/tui/src/chatwidget/hooks.rs @@ -36,6 +36,7 @@ impl ChatWidget { .show_view(Box::new(HooksBrowserView::from_entry( entry, self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), ))); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs new file mode 100644 index 0000000000..704d5dcac6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -0,0 +1,206 @@ +//! User input submission, queue draining, and draft restore flow for `ChatWidget`. +//! +//! The queue data itself lives in `input_queue`; this module owns the app-level +//! effects around taking composer input, submitting user turns, draining queued +//! follow-ups, and restoring draft state across interrupts or thread switches. + +use super::*; + +impl ChatWidget { + pub(super) fn handle_composer_input_result( + &mut self, + input_result: InputResult, + had_modal_or_popup: bool, + ) { + match input_result { + InputResult::Submitted { + text, + text_elements, + } => { + let user_message = self.user_message_from_submission(text, text_elements); + if user_message.text.is_empty() + && user_message.local_images.is_empty() + && user_message.remote_image_urls.is_empty() + { + return; + } + let should_submit_now = + self.is_session_configured() && !self.is_plan_streaming_in_tui(); + if should_submit_now { + if self.only_user_shell_commands_running() + && !user_message.text.starts_with('!') + { + self.queue_user_message(user_message); + return; + } + // Submitted is emitted when user submits. + // Reset any reasoning header only when we are actually submitting a turn. + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.set_status_header(String::from("Working")); + self.submit_user_message(user_message); + } else { + self.queue_user_message(user_message); + } + } + InputResult::Queued { + text, + text_elements, + action, + } => { + let user_message = self.user_message_from_submission(text, text_elements); + self.queue_user_message_with_options(user_message, action); + } + InputResult::Command(cmd) => { + self.handle_slash_command_dispatch(cmd); + } + InputResult::ServiceTierCommand(command) => { + self.handle_service_tier_command_dispatch(command); + } + InputResult::CommandWithArgs(cmd, args, text_elements) => { + self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); + } + InputResult::None => {} + } + if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() { + self.maybe_send_next_queued_input(); + } + self.refresh_plan_mode_nudge(); + } + + pub(super) fn queue_user_message(&mut self, user_message: UserMessage) { + self.queue_user_message_with_options(user_message, QueuedInputAction::Plain); + } + + pub(super) fn queue_user_message_with_options( + &mut self, + user_message: UserMessage, + action: QueuedInputAction, + ) { + if !self.is_session_configured() || self.is_user_turn_pending_or_running() { + self.input_queue + .queued_user_messages + .push_back(QueuedUserMessage::new(user_message, action)); + self.input_queue + .queued_user_message_history_records + .push_back(UserMessageHistoryRecord::UserMessageText); + self.refresh_pending_input_preview(); + } else { + self.submit_user_message(user_message); + } + } + + /// If idle and there are queued inputs, submit exactly one to start the next turn. + pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool { + if self.input_queue.suppress_queue_autosend { + return false; + } + if self.is_user_turn_pending_or_running() { + return false; + } + let mut submitted_follow_up = false; + while !self.is_user_turn_pending_or_running() { + let Some((queued_message, history_record)) = self.pop_next_queued_user_message() else { + break; + }; + match queued_message.action { + QueuedInputAction::Plain => { + submitted_follow_up = self.submit_user_message_with_history_record( + queued_message.into_user_message(), + history_record, + ); + break; + } + QueuedInputAction::ParseSlash => { + let drain = self.submit_queued_slash_prompt(queued_message.into_user_message()); + if drain == QueueDrain::Stop { + submitted_follow_up = self.is_user_turn_pending_or_running(); + break; + } + } + QueuedInputAction::RunShell => { + let drain = self.submit_queued_shell_prompt(queued_message.into_user_message()); + if drain == QueueDrain::Stop { + submitted_follow_up = self.is_user_turn_pending_or_running(); + break; + } + } + } + } + // Update the list to reflect the remaining queued messages (if any). + self.refresh_pending_input_preview(); + submitted_follow_up + } + + pub(super) fn is_user_turn_pending_or_running(&self) -> bool { + self.input_queue.user_turn_pending_start || self.bottom_pane.is_task_running() + } + + pub(super) fn only_user_shell_commands_running(&self) -> bool { + self.turn_lifecycle.agent_turn_running + && !self.running_commands.is_empty() + && self + .running_commands + .values() + .all(|command| command.source == ExecCommandSource::UserShell) + } + + /// Rebuild and update the bottom-pane pending-input preview. + pub(super) fn refresh_pending_input_preview(&mut self) { + let preview = self.input_queue.preview(); + self.bottom_pane.set_pending_input_preview( + preview.queued_messages, + preview.pending_steers, + preview.rejected_steers, + ); + } + + pub(crate) fn submit_user_message_with_mode( + &mut self, + text: String, + mut collaboration_mode: CollaborationModeMask, + ) { + if collaboration_mode.mode == Some(ModeKind::Plan) + && let Some(effort) = self.config.plan_mode_reasoning_effort + { + collaboration_mode.reasoning_effort = Some(Some(effort)); + } + if self.turn_lifecycle.agent_turn_running + && self.active_collaboration_mask.as_ref() != Some(&collaboration_mode) + { + self.add_error_message( + "Cannot switch collaboration mode while a turn is running.".to_string(), + ); + return; + } + self.set_collaboration_mask(collaboration_mode); + let should_queue = self.is_plan_streaming_in_tui(); + let user_message = UserMessage { + text, + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }; + if should_queue { + self.queue_user_message(user_message); + } else { + self.submit_user_message(user_message); + } + } + + #[cfg(test)] + pub(crate) fn queued_user_message_texts(&self) -> Vec { + self.input_queue + .rejected_steers_queue + .iter() + .map(|message| message.text.clone()) + .chain( + self.input_queue + .queued_user_messages + .iter() + .map(|message| message.text.clone()), + ) + .collect() + } +} diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs new file mode 100644 index 0000000000..ca15f1cdf7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -0,0 +1,380 @@ +//! Input queue restore and thread-input snapshot behavior for `ChatWidget`. + +use super::*; + +impl ChatWidget { + pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) { + self.suppress_initial_user_message_submit = suppressed; + } + + pub(crate) fn submit_initial_user_message_if_pending(&mut self) { + if let Some(user_message) = self.initial_user_message.take() { + self.submit_user_message(user_message); + } + } + + pub(super) fn pop_next_queued_user_message( + &mut self, + ) -> Option<(QueuedUserMessage, UserMessageHistoryRecord)> { + if self.input_queue.rejected_steers_queue.is_empty() { + self.input_queue + .queued_user_messages + .pop_front() + .map(|user_message| { + let history_record = self + .input_queue + .queued_user_message_history_records + .pop_front() + .unwrap_or(UserMessageHistoryRecord::UserMessageText); + (user_message, history_record) + }) + } else { + let rejected_messages = self + .input_queue + .rejected_steers_queue + .drain(..) + .collect::>(); + let mut history_records = self + .input_queue + .rejected_steer_history_records + .drain(..) + .collect::>(); + history_records.resize( + rejected_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); + let (message, history_record) = merge_user_messages_with_history_record( + rejected_messages + .into_iter() + .zip(history_records) + .collect::>(), + ); + Some((QueuedUserMessage::from(message), history_record)) + } + } + + pub(super) fn pop_latest_queued_user_message(&mut self) -> Option { + if let Some(user_message) = self.input_queue.queued_user_messages.pop_back() { + let history_record = self + .input_queue + .queued_user_message_history_records + .pop_back() + .unwrap_or(UserMessageHistoryRecord::UserMessageText); + Some(user_message_for_restore( + user_message.into_user_message(), + &history_record, + )) + } else { + let user_message = self.input_queue.rejected_steers_queue.pop_back()?; + let history_record = self + .input_queue + .rejected_steer_history_records + .pop_back() + .unwrap_or(UserMessageHistoryRecord::UserMessageText); + Some(user_message_for_restore(user_message, &history_record)) + } + } + + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { + let Some(pending_steer) = self.input_queue.pending_steers.pop_front() else { + tracing::warn!( + "received active-turn-not-steerable error without a matching pending steer" + ); + return false; + }; + self.input_queue + .rejected_steers_queue + .push_back(pending_steer.user_message); + self.input_queue + .rejected_steer_history_records + .push_back(pending_steer.history_record); + self.refresh_pending_input_preview(); + true + } + + /// Handle a turn aborted due to user interrupt (Esc), budget exhaustion, + /// or review completion. + /// When there are queued user messages, restore them into the composer + /// separated by newlines rather than auto-submitting the next one. + pub(super) fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { + // Finalize, log a gentle prompt, and clear running state. + self.finalize_turn(); + let send_pending_steers_immediately = + self.input_queue.submit_pending_steers_after_interrupt; + self.input_queue.submit_pending_steers_after_interrupt = false; + if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress { + if send_pending_steers_immediately { + self.add_to_history(history_cell::new_info_event( + "Model interrupted to submit steer instructions.".to_owned(), + /*hint*/ None, + )); + } else { + self.add_to_history(history_cell::new_error_event( + self.interrupted_turn_message(reason), + )); + } + } + + // The server has already discarded pending input by the time the + // interrupted turn reaches the UI, so any unacknowledged steers still + // tracked here must be restored locally instead of waiting for a later commit. + if send_pending_steers_immediately { + let pending_steers = self + .input_queue + .pending_steers + .drain(..) + .map(|pending| (pending.user_message, pending.history_record)) + .collect::>(); + if !pending_steers.is_empty() { + let (user_message, history_record) = + merge_user_messages_with_history_record(pending_steers); + self.submit_user_message_with_history_record(user_message, history_record); + } else if let Some(combined) = self.drain_pending_messages_for_restore() { + self.restore_user_message_to_composer(combined); + } + } else if let Some(combined) = self.drain_pending_messages_for_restore() { + self.restore_user_message_to_composer(combined); + } + self.refresh_pending_input_preview(); + + self.request_redraw(); + } + + /// Merge pending steers, queued drafts, and the current composer state into a single message. + /// + /// Each pending message numbers attachments from `[Image #1]` relative to its own remote + /// images. When we concatenate multiple messages after interrupt, we must renumber local-image + /// placeholders in a stable order and rebase text element byte ranges so the restored composer + /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to + /// restore. + fn drain_pending_messages_for_restore(&mut self) -> Option { + if self.input_queue.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { + return None; + } + + let existing_message = UserMessage { + text: self.bottom_pane.composer_text(), + text_elements: self.bottom_pane.composer_text_elements(), + local_images: self.bottom_pane.composer_local_images(), + remote_image_urls: self.bottom_pane.remote_image_urls(), + mention_bindings: self.bottom_pane.composer_mention_bindings(), + }; + + let rejected_messages = self + .input_queue + .rejected_steers_queue + .drain(..) + .collect::>(); + let mut rejected_history_records = self + .input_queue + .rejected_steer_history_records + .drain(..) + .collect::>(); + rejected_history_records.resize( + rejected_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); + let mut to_merge: Vec = rejected_messages + .into_iter() + .zip(rejected_history_records.iter()) + .map(|(message, history_record)| user_message_for_restore(message, history_record)) + .collect(); + to_merge.extend( + self.input_queue + .pending_steers + .drain(..) + .map(|steer| user_message_for_restore(steer.user_message, &steer.history_record)), + ); + let queued_messages = self + .input_queue + .queued_user_messages + .drain(..) + .collect::>(); + let mut queued_history_records = self + .input_queue + .queued_user_message_history_records + .drain(..) + .collect::>(); + queued_history_records.resize( + queued_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); + to_merge.extend( + queued_messages + .into_iter() + .zip(queued_history_records.iter()) + .map(|(message, history_record)| { + user_message_for_restore(message.into_user_message(), history_record) + }), + ); + if !existing_message.text.is_empty() + || !existing_message.local_images.is_empty() + || !existing_message.remote_image_urls.is_empty() + { + to_merge.push(existing_message); + } + + Some(merge_user_messages(to_merge)) + } + + pub(crate) fn restore_user_message_to_composer(&mut self, user_message: UserMessage) { + let UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } = user_message; + let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); + self.set_remote_image_urls(remote_image_urls); + self.bottom_pane.set_composer_text_with_mention_bindings( + text, + text_elements, + local_image_paths, + mention_bindings, + ); + } + + pub(crate) fn capture_thread_input_state(&self) -> Option { + let composer = ThreadComposerState { + text: self.bottom_pane.composer_text(), + text_elements: self.bottom_pane.composer_text_elements(), + local_images: self.bottom_pane.composer_local_images(), + remote_image_urls: self.bottom_pane.remote_image_urls(), + mention_bindings: self.bottom_pane.composer_mention_bindings(), + pending_pastes: self.bottom_pane.composer_pending_pastes(), + }; + Some(ThreadInputState { + composer: composer.has_content().then_some(composer), + pending_steers: self + .input_queue + .pending_steers + .iter() + .map(|pending| pending.user_message.clone()) + .collect(), + pending_steer_history_records: self + .input_queue + .pending_steers + .iter() + .map(|pending| pending.history_record.clone()) + .collect(), + pending_steer_compare_keys: self + .input_queue + .pending_steers + .iter() + .map(|pending| pending.compare_key.clone()) + .collect(), + rejected_steers_queue: self.input_queue.rejected_steers_queue.clone(), + rejected_steer_history_records: self.input_queue.rejected_steer_history_records.clone(), + queued_user_messages: self.input_queue.queued_user_messages.clone(), + queued_user_message_history_records: self + .input_queue + .queued_user_message_history_records + .clone(), + user_turn_pending_start: self.input_queue.user_turn_pending_start, + current_collaboration_mode: self.current_collaboration_mode.clone(), + active_collaboration_mask: self.active_collaboration_mask.clone(), + task_running: self.bottom_pane.is_task_running(), + agent_turn_running: self.turn_lifecycle.agent_turn_running, + }) + } + + pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); + if let Some(input_state) = input_state { + self.current_collaboration_mode = input_state.current_collaboration_mode; + self.active_collaboration_mask = input_state.active_collaboration_mask; + self.turn_lifecycle + .restore_running(input_state.agent_turn_running, Instant::now()); + self.input_queue.user_turn_pending_start = input_state.user_turn_pending_start; + self.update_collaboration_mode_indicator(); + self.refresh_model_dependent_surfaces(); + if let Some(composer) = input_state.composer { + let local_image_paths = composer + .local_images + .into_iter() + .map(|img| img.path) + .collect(); + self.set_remote_image_urls(composer.remote_image_urls); + self.bottom_pane.set_composer_text_with_mention_bindings( + composer.text, + composer.text_elements, + local_image_paths, + composer.mention_bindings, + ); + self.bottom_pane + .set_composer_pending_pastes(composer.pending_pastes); + } else { + self.set_remote_image_urls(Vec::new()); + self.bottom_pane.set_composer_text_with_mention_bindings( + String::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + self.bottom_pane.set_composer_pending_pastes(Vec::new()); + } + let mut pending_steer_history_records = input_state.pending_steer_history_records; + pending_steer_history_records.resize( + input_state.pending_steers.len(), + UserMessageHistoryRecord::UserMessageText, + ); + let mut pending_steer_compare_keys = input_state.pending_steer_compare_keys; + self.input_queue.pending_steers = input_state + .pending_steers + .into_iter() + .zip(pending_steer_history_records) + .map(|(user_message, history_record)| PendingSteer { + compare_key: pending_steer_compare_keys.pop_front().unwrap_or_else(|| { + PendingSteerCompareKey { + message: user_message.text.clone(), + image_count: user_message.local_images.len() + + user_message.remote_image_urls.len(), + } + }), + history_record, + user_message, + }) + .collect(); + self.input_queue.rejected_steers_queue = input_state.rejected_steers_queue; + self.input_queue.rejected_steer_history_records = + input_state.rejected_steer_history_records; + self.input_queue.rejected_steer_history_records.resize( + self.input_queue.rejected_steers_queue.len(), + UserMessageHistoryRecord::UserMessageText, + ); + self.input_queue.queued_user_messages = input_state.queued_user_messages; + self.input_queue.queued_user_message_history_records = + input_state.queued_user_message_history_records; + self.input_queue.queued_user_message_history_records.resize( + self.input_queue.queued_user_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); + } else { + self.turn_lifecycle + .restore_running(/*running*/ false, Instant::now()); + self.input_queue.clear(); + self.set_remote_image_urls(Vec::new()); + self.bottom_pane.set_composer_text_with_mention_bindings( + String::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + self.bottom_pane.set_composer_pending_pastes(Vec::new()); + } + self.turn_lifecycle + .restore_running(self.turn_lifecycle.agent_turn_running, Instant::now()); + self.update_task_running_state(); + if restored_task_running && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + self.refresh_status_surfaces(); + } + self.refresh_pending_input_preview(); + self.request_redraw(); + } + + pub(crate) fn set_queue_autosend_suppressed(&mut self, suppressed: bool) { + self.input_queue.suppress_queue_autosend = suppressed; + } +} diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs new file mode 100644 index 0000000000..9043cb3d9d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -0,0 +1,439 @@ +//! User-message and shell-prompt submission behavior for `ChatWidget`. + +use super::*; + +impl ChatWidget { + pub(super) fn user_message_from_submission( + &mut self, + text: String, + text_elements: Vec, + ) -> UserMessage { + let local_images = self + .bottom_pane + .take_recent_submission_images_with_placeholders(); + let remote_image_urls = self.take_remote_image_urls(); + UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), + } + } + + fn submit_shell_command(&mut self, command: &str) -> QueueDrain { + let cmd = command.trim(); + if cmd.is_empty() { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + USER_SHELL_COMMAND_HELP_TITLE.to_string(), + Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), + ), + ))); + QueueDrain::Continue + } else { + self.submit_op(AppCommand::run_user_shell_command(cmd.to_string())); + QueueDrain::Stop + } + } + + fn submit_shell_command_with_history( + &mut self, + command: &str, + history_text: &str, + ) -> QueueDrain { + let drain = self.submit_shell_command(command); + if drain == QueueDrain::Stop { + self.append_message_history_entry(history_text.to_string()); + } + drain + } + + pub(super) fn submit_queued_shell_prompt(&mut self, user_message: UserMessage) -> QueueDrain { + match user_message.text.strip_prefix('!') { + Some(command) => { + let history_text = user_message.text.clone(); + self.submit_shell_command_with_history(command, &history_text) + } + None => { + self.submit_user_message(user_message); + QueueDrain::Stop + } + } + } + + pub(super) fn submit_user_message(&mut self, user_message: UserMessage) { + let _accepted = self.submit_user_message_with_history_record( + user_message, + UserMessageHistoryRecord::UserMessageText, + ); + } + + pub(super) fn submit_user_message_with_history_record( + &mut self, + user_message: UserMessage, + history_record: UserMessageHistoryRecord, + ) -> bool { + self.submit_user_message_with_history_and_shell_escape_policy( + user_message, + history_record, + ShellEscapePolicy::Allow, + ) + .0 + } + + pub(super) fn submit_user_message_with_shell_escape_policy( + &mut self, + user_message: UserMessage, + shell_escape_policy: ShellEscapePolicy, + ) -> Option { + self.submit_user_message_with_history_and_shell_escape_policy( + user_message, + UserMessageHistoryRecord::UserMessageText, + shell_escape_policy, + ) + .1 + } + + fn submit_user_message_with_history_and_shell_escape_policy( + &mut self, + user_message: UserMessage, + history_record: UserMessageHistoryRecord, + shell_escape_policy: ShellEscapePolicy, + ) -> (bool, Option) { + if !self.is_session_configured() { + tracing::warn!("cannot submit user message before session is configured; queueing"); + self.input_queue + .queued_user_messages + .push_front(QueuedUserMessage::from(user_message)); + self.input_queue + .queued_user_message_history_records + .push_front(history_record); + self.refresh_pending_input_preview(); + return (true, None); + } + if user_message.text.is_empty() + && user_message.local_images.is_empty() + && user_message.remote_image_urls.is_empty() + { + return (false, None); + } + if (!user_message.local_images.is_empty() || !user_message.remote_image_urls.is_empty()) + && !self.current_model_supports_images() + { + let UserMessage { + text, + text_elements, + local_images, + mention_bindings, + remote_image_urls, + } = user_message_for_restore(user_message, &history_record); + self.restore_blocked_image_submission( + text, + text_elements, + local_images, + mention_bindings, + remote_image_urls, + ); + return (false, None); + } + let UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } = user_message; + + let render_in_history = !self.turn_lifecycle.agent_turn_running; + let mut items: Vec = Vec::new(); + + // Special-case: "!cmd" executes a local shell command instead of sending to the model. + if shell_escape_policy == ShellEscapePolicy::Allow + && let Some(stripped) = text.strip_prefix('!') + { + let app_command = match self.submit_shell_command_with_history(stripped, &text) { + QueueDrain::Continue => None, + QueueDrain::Stop => Some(AppCommand::run_user_shell_command( + stripped.trim().to_string(), + )), + }; + return (app_command.is_some(), app_command); + } + + for image_url in &remote_image_urls { + items.push(UserInput::Image { + url: image_url.clone(), + }); + } + + for image in &local_images { + items.push(UserInput::LocalImage { + path: image.path.clone(), + }); + } + + if !text.is_empty() { + items.push(UserInput::Text { + text: text.clone(), + text_elements: app_server_text_elements(&text_elements), + }); + } + + let mentions = collect_tool_mentions(&text, &HashMap::new()); + let bound_names: HashSet = mention_bindings + .iter() + .map(|binding| binding.mention.clone()) + .collect(); + let mut skill_names_lower: HashSet = HashSet::new(); + let mut selected_skill_paths: HashSet = HashSet::new(); + let mut selected_plugin_ids: HashSet = HashSet::new(); + + if let Some(skills) = self.bottom_pane.skills() { + skill_names_lower = skills + .iter() + .map(|skill| skill.name.to_ascii_lowercase()) + .collect(); + + for binding in &mention_bindings { + let path = binding + .path + .strip_prefix("skill://") + .unwrap_or(binding.path.as_str()); + let path = Path::new(path); + if let Some(skill) = skills + .iter() + .find(|skill| skill.path_to_skills_md.as_path() == path) + && selected_skill_paths.insert(skill.path_to_skills_md.clone()) + { + items.push(UserInput::Skill { + name: skill.name.clone(), + path: skill.path_to_skills_md.to_path_buf(), + }); + } + } + + let skill_mentions = find_skill_mentions_with_tool_mentions(&mentions, skills); + for skill in skill_mentions { + if bound_names.contains(skill.name.as_str()) + || !selected_skill_paths.insert(skill.path_to_skills_md.clone()) + { + continue; + } + items.push(UserInput::Skill { + name: skill.name.clone(), + path: skill.path_to_skills_md.to_path_buf(), + }); + } + } + + if let Some(plugins) = self.plugins_for_mentions() { + for binding in &mention_bindings { + let Some(plugin_config_name) = binding + .path + .strip_prefix("plugin://") + .filter(|id| !id.is_empty()) + else { + continue; + }; + if !selected_plugin_ids.insert(plugin_config_name.to_string()) { + continue; + } + if let Some(plugin) = plugins + .iter() + .find(|plugin| plugin.config_name == plugin_config_name) + { + items.push(UserInput::Mention { + name: plugin.display_name.clone(), + path: binding.path.clone(), + }); + } + } + } + + let mut selected_app_ids: HashSet = HashSet::new(); + if let Some(apps) = self.connectors_for_mentions() { + for binding in &mention_bindings { + let Some(app_id) = binding + .path + .strip_prefix("app://") + .filter(|id| !id.is_empty()) + else { + continue; + }; + if !selected_app_ids.insert(app_id.to_string()) { + continue; + } + if let Some(app) = apps.iter().find(|app| app.id == app_id && app.is_enabled) { + items.push(UserInput::Mention { + name: app.name.clone(), + path: binding.path.clone(), + }); + } + } + + let app_mentions = find_app_mentions(&mentions, apps, &skill_names_lower); + for app in app_mentions { + let slug = codex_connectors::metadata::connector_mention_slug(&app); + if bound_names.contains(&slug) || !selected_app_ids.insert(app.id.clone()) { + continue; + } + let app_id = app.id.as_str(); + items.push(UserInput::Mention { + name: app.name.clone(), + path: format!("app://{app_id}"), + }); + } + } + + let effective_mode = self.effective_collaboration_mode(); + if effective_mode.model().trim().is_empty() { + self.add_error_message( + "Thread model is unavailable. Wait for the thread to finish syncing or choose a model before sending input.".to_string(), + ); + self.restore_user_message_to_composer(user_message_for_restore( + UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + }, + &history_record, + )); + return (false, None); + } + + self.maybe_apply_ide_context(&mut items); + + let collaboration_mode = if self.collaboration_modes_enabled() { + self.active_collaboration_mask + .as_ref() + .map(|_| effective_mode.clone()) + } else { + None + }; + let pending_steer = (!render_in_history).then(|| PendingSteer { + user_message: UserMessage { + text: text.clone(), + local_images: local_images.clone(), + remote_image_urls: remote_image_urls.clone(), + text_elements: text_elements.clone(), + mention_bindings: mention_bindings.clone(), + }, + history_record: history_record.clone(), + compare_key: Self::pending_steer_compare_key_from_items(&items), + }); + let personality = self + .config + .personality + .filter(|_| self.config.features.enabled(Feature::Personality)) + .filter(|_| self.current_model_supports_personality()); + let service_tier = match self.config.service_tier.clone() { + Some(service_tier) => Some(Some(service_tier)), + None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), + None => None, + }; + let permission_profile = self.config.permissions.permission_profile(); + let op = AppCommand::user_turn( + items, + self.config.cwd.to_path_buf(), + AskForApproval::from(self.config.permissions.approval_policy.value()), + permission_profile, + effective_mode.model().to_string(), + effective_mode.reasoning_effort(), + /*summary*/ None, + service_tier, + /*final_output_json_schema*/ None, + collaboration_mode, + personality, + ); + + if !self.submit_op(op.clone()) { + return (false, None); + } + if render_in_history { + self.input_queue.user_turn_pending_start = true; + } + + // Persist the submitted text to cross-session message history. Mentions are encoded into + // placeholder syntax so recall can reconstruct the mention bindings in a future session. + let encoded_mentions = mention_bindings + .iter() + .map(|binding| LinkedMention { + mention: binding.mention.clone(), + path: binding.path.clone(), + }) + .collect::>(); + let history_text = match &history_record { + UserMessageHistoryRecord::UserMessageText if !text.is_empty() => { + Some(encode_history_mentions(&text, &encoded_mentions)) + } + UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { + Some(encode_history_mentions(&history.text, &encoded_mentions)) + } + UserMessageHistoryRecord::UserMessageText | UserMessageHistoryRecord::Override(_) => { + None + } + }; + if let Some(history_text) = history_text { + self.append_message_history_entry(history_text); + } + + if let Some(pending_steer) = pending_steer { + self.input_queue.pending_steers.push_back(pending_steer); + self.transcript.saw_plan_item_this_turn = false; + self.refresh_pending_input_preview(); + } + + // Show replayable user content in conversation history. + let display_user_message = render_in_history.then(|| { + user_message_display_for_history( + UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + }, + &history_record, + ) + }); + if let Some(display) = display_user_message { + self.on_user_message_display(display); + } + + self.transcript.needs_final_message_separator = false; + (true, Some(op)) + } + + /// Restore the blocked submission draft without losing mention resolution state. + /// + /// The blocked-image path intentionally keeps the draft in the composer so + /// users can remove attachments and retry. We must restore + /// mention bindings alongside visible text; restoring only `$name` tokens + /// makes the draft look correct while degrading mention resolution to + /// name-only heuristics on retry. + pub(super) fn restore_blocked_image_submission( + &mut self, + text: String, + text_elements: Vec, + local_images: Vec, + mention_bindings: Vec, + remote_image_urls: Vec, + ) { + // Preserve the user's composed payload so they can retry after changing models. + let local_image_paths = local_images.iter().map(|img| img.path.clone()).collect(); + self.set_remote_image_urls(remote_image_urls); + self.bottom_pane.set_composer_text_with_mention_bindings( + text, + text_elements, + local_image_paths, + mention_bindings, + ); + self.add_to_history(history_cell::new_warning_event( + self.image_inputs_not_supported_message(), + )); + self.request_redraw(); + } +} diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 82ac8eba7c..401f9217b6 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -60,6 +60,18 @@ const PLUGIN_ROW_PREFIX_WIDTH: usize = 6; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); +#[derive(Debug, Clone, Default)] +pub(super) struct PluginListFetchState { + pub(super) cache_cwd: Option, + pub(super) in_flight_cwd: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct PluginInstallAuthFlowState { + plugin_display_name: String, + next_app_index: usize, +} + struct DelayedLoadingHeader { started_at: Instant, frame_requester: FrameRequester, @@ -474,7 +486,7 @@ impl ChatWidget { self.plugin_install_apps_needing_auth.len() )), ); - self.plugin_install_auth_flow = Some(super::PluginInstallAuthFlowState { + self.plugin_install_auth_flow = Some(PluginInstallAuthFlowState { plugin_display_name, next_app_index: 0, }); diff --git a/codex-rs/tui/src/chatwidget/protocol_requests.rs b/codex-rs/tui/src/chatwidget/protocol_requests.rs new file mode 100644 index 0000000000..5b53f44fdf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/protocol_requests.rs @@ -0,0 +1,148 @@ +//! App-server request and notification dispatch for `ChatWidget`. +//! +//! This module translates protocol requests into the focused chat-widget flows +//! that render approvals, permissions, tool input, and guardian reviews. + +use super::*; + +impl ChatWidget { + pub(crate) fn handle_server_request( + &mut self, + request: ServerRequest, + replay_kind: Option, + ) { + let id = request.id().to_string(); + match request { + ServerRequest::CommandExecutionRequestApproval { params, .. } => { + let fallback_cwd = self.config.cwd.clone(); + self.on_exec_approval_request( + id, + exec_approval_request_from_params(params, &fallback_cwd), + ); + } + ServerRequest::FileChangeRequestApproval { params, .. } => { + self.on_apply_patch_approval_request( + id, + patch_approval_request_from_params(params), + ); + } + ServerRequest::McpServerElicitationRequest { request_id, params } => { + self.on_elicitation_request(request_id, params); + } + ServerRequest::PermissionsRequestApproval { params, .. } => { + self.on_request_permissions(request_permissions_from_params(params)); + } + ServerRequest::ToolRequestUserInput { params, .. } => { + self.on_request_user_input(params); + } + ServerRequest::DynamicToolCall { .. } + | ServerRequest::AttestationGenerate { .. } + | ServerRequest::ChatgptAuthTokensRefresh { .. } + | ServerRequest::ApplyPatchApproval { .. } + | ServerRequest::ExecCommandApproval { .. } => { + if replay_kind.is_none() { + self.add_error_message(TUI_STUB_MESSAGE.to_string()); + } + } + } + } + + pub(crate) fn handle_skills_list_response(&mut self, response: SkillsListResponse) { + self.on_list_skills(response); + } + + pub(super) fn on_patch_apply_output_delta(&mut self, _item_id: String, _delta: String) {} + + pub(super) fn on_guardian_review_notification( + &mut self, + id: String, + turn_id: String, + started_at_ms: i64, + review: codex_app_server_protocol::GuardianApprovalReview, + completion: Option<(i64, codex_app_server_protocol::AutoReviewDecisionSource)>, + action: GuardianApprovalReviewAction, + ) { + let (completed_at_ms, decision_source) = match completion { + Some((completed_at_ms, decision_source)) => { + (Some(completed_at_ms), Some(decision_source)) + } + None => (None, None), + }; + + self.on_guardian_assessment(GuardianAssessmentEvent { + id, + target_item_id: None, + turn_id, + started_at_ms, + completed_at_ms, + status: match review.status { + codex_app_server_protocol::GuardianApprovalReviewStatus::InProgress => { + GuardianAssessmentStatus::InProgress + } + codex_app_server_protocol::GuardianApprovalReviewStatus::Approved => { + GuardianAssessmentStatus::Approved + } + codex_app_server_protocol::GuardianApprovalReviewStatus::Denied => { + GuardianAssessmentStatus::Denied + } + codex_app_server_protocol::GuardianApprovalReviewStatus::TimedOut => { + GuardianAssessmentStatus::TimedOut + } + codex_app_server_protocol::GuardianApprovalReviewStatus::Aborted => { + GuardianAssessmentStatus::Aborted + } + }, + risk_level: review.risk_level.map(|risk_level| match risk_level { + codex_app_server_protocol::GuardianRiskLevel::Low => { + codex_protocol::approvals::GuardianRiskLevel::Low + } + codex_app_server_protocol::GuardianRiskLevel::Medium => { + codex_protocol::approvals::GuardianRiskLevel::Medium + } + codex_app_server_protocol::GuardianRiskLevel::High => { + codex_protocol::approvals::GuardianRiskLevel::High + } + codex_app_server_protocol::GuardianRiskLevel::Critical => { + codex_protocol::approvals::GuardianRiskLevel::Critical + } + }), + user_authorization: review.user_authorization.map(|user_authorization| { + match user_authorization { + codex_app_server_protocol::GuardianUserAuthorization::Unknown => { + codex_protocol::approvals::GuardianUserAuthorization::Unknown + } + codex_app_server_protocol::GuardianUserAuthorization::Low => { + codex_protocol::approvals::GuardianUserAuthorization::Low + } + codex_app_server_protocol::GuardianUserAuthorization::Medium => { + codex_protocol::approvals::GuardianUserAuthorization::Medium + } + codex_app_server_protocol::GuardianUserAuthorization::High => { + codex_protocol::approvals::GuardianUserAuthorization::High + } + } + }), + rationale: review.rationale, + decision_source: decision_source.map(|source| match source { + codex_app_server_protocol::AutoReviewDecisionSource::Agent => { + GuardianAssessmentDecisionSource::Agent + } + }), + action: action.into(), + }); + } + + pub(super) fn on_shutdown_complete(&mut self) { + self.request_immediate_exit(); + } + + pub(super) fn on_turn_diff(&mut self, unified_diff: String) { + debug!("TurnDiffEvent: {unified_diff}"); + self.refresh_status_line(); + } + + pub(super) fn on_deprecation_notice(&mut self, summary: String, details: Option) { + self.add_to_history(history_cell::new_deprecation_notice(summary, details)); + self.request_redraw(); + } +} diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs new file mode 100644 index 0000000000..8d5bd89aee --- /dev/null +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -0,0 +1,127 @@ +//! Rate-limit warning and prompt state for `ChatWidget`. + +use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; + +pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; +pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; + +const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; + +#[derive(Default)] +pub(super) struct RateLimitWarningState { + pub(super) secondary_index: usize, + pub(super) primary_index: usize, +} + +impl RateLimitWarningState { + pub(super) fn take_warnings( + &mut self, + secondary_used_percent: Option, + secondary_window_minutes: Option, + primary_used_percent: Option, + primary_window_minutes: Option, + ) -> Vec { + let reached_secondary_cap = + matches!(secondary_used_percent, Some(percent) if percent == 100.0); + let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0); + if reached_secondary_cap || reached_primary_cap { + return Vec::new(); + } + + let mut warnings = Vec::new(); + + if let Some(secondary_used_percent) = secondary_used_percent { + let mut highest_secondary: Option = None; + while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() + && secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index] + { + highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]); + self.secondary_index += 1; + } + if let Some(threshold) = highest_secondary { + let limit_label = secondary_window_minutes + .map(get_limits_duration) + .unwrap_or_else(|| "weekly".to_string()); + let remaining_percent = 100.0 - threshold; + warnings.push(format!( + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." + )); + } + } + + if let Some(primary_used_percent) = primary_used_percent { + let mut highest_primary: Option = None; + while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() + && primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index] + { + highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]); + self.primary_index += 1; + } + if let Some(threshold) = highest_primary { + let limit_label = primary_window_minutes + .map(get_limits_duration) + .unwrap_or_else(|| "5h".to_string()); + let remaining_percent = 100.0 - threshold; + warnings.push(format!( + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." + )); + } + } + + warnings + } +} + +pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { + const MINUTES_PER_HOUR: i64 = 60; + const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; + const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; + const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; + const ROUNDING_BIAS_MINUTES: i64 = 3; + + let windows_minutes = windows_minutes.max(0); + + if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { + let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); + let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR); + format!("{hours}h") + } else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) { + "weekly".to_string() + } else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) { + "monthly".to_string() + } else { + "annual".to_string() + } +} + +#[derive(Default)] +pub(super) enum RateLimitSwitchPromptState { + #[default] + Idle, + Pending, + Shown, +} + +#[derive(Debug)] +pub(super) enum RateLimitErrorKind { + ServerOverloaded, + UsageLimit, + Generic, +} + +pub(super) fn app_server_rate_limit_error_kind( + info: &AppServerCodexErrorInfo, +) -> Option { + match info { + AppServerCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded), + AppServerCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit), + AppServerCodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(429), + } => Some(RateLimitErrorKind::Generic), + _ => None, + } +} + +pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool { + matches!(info, AppServerCodexErrorInfo::CyberPolicy) +} diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs new file mode 100644 index 0000000000..294e07e33a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -0,0 +1,196 @@ +//! Thread replay rendering for `ChatWidget`. +//! +//! This module rehydrates turns and items into transcript state while avoiding +//! live-only side effects. + +use super::*; + +impl ChatWidget { + /// Replay a subset of initial events into the UI to seed the transcript when + /// resuming an existing session. This approximates the live event flow and + /// is intentionally conservative: only safe-to-replay items are rendered to + /// avoid triggering side effects. Event ids are passed as `None` to + /// distinguish replayed events from live ones. + pub(crate) fn replay_thread_turns(&mut self, turns: Vec, replay_kind: ReplayKind) { + for turn in turns { + let Turn { + id: turn_id, + items_view: _, + items, + status, + error, + started_at, + completed_at, + duration_ms, + } = turn; + if matches!(status, TurnStatus::InProgress) { + self.last_non_retry_error = None; + self.on_task_started(); + } + for item in items { + self.replay_thread_item(item, turn_id.clone(), replay_kind); + } + if matches!( + status, + TurnStatus::Completed | TurnStatus::Interrupted | TurnStatus::Failed + ) { + self.handle_turn_completed_notification( + TurnCompletedNotification { + thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(), + turn: Turn { + id: turn_id, + items_view: codex_app_server_protocol::TurnItemsView::NotLoaded, + items: Vec::new(), + status, + error, + started_at, + completed_at, + duration_ms, + }, + }, + Some(replay_kind), + ); + } + } + } + + pub(crate) fn replay_thread_item( + &mut self, + item: ThreadItem, + turn_id: String, + replay_kind: ReplayKind, + ) { + self.handle_thread_item(item, turn_id, ThreadItemRenderSource::Replay(replay_kind)); + } + + pub(super) fn handle_thread_item( + &mut self, + item: ThreadItem, + turn_id: String, + render_source: ThreadItemRenderSource, + ) { + let from_replay = render_source.is_replay(); + let replay_kind = render_source.replay_kind(); + match item { + ThreadItem::UserMessage { content, .. } => { + self.on_committed_user_message(&content, from_replay); + } + ThreadItem::AgentMessage { + id, + text, + phase, + memory_citation, + } => { + self.on_agent_message_item_completed( + AgentMessageItem { + id, + content: vec![AgentMessageContent::Text { text }], + phase, + memory_citation: memory_citation.map(|citation| { + codex_protocol::memory_citation::MemoryCitation { + entries: citation + .entries + .into_iter() + .map(|entry| { + codex_protocol::memory_citation::MemoryCitationEntry { + path: entry.path, + line_start: entry.line_start, + line_end: entry.line_end, + note: entry.note, + } + }) + .collect(), + rollout_ids: citation.thread_ids, + } + }), + }, + from_replay, + ); + } + ThreadItem::Plan { text, .. } => self.on_plan_item_completed(text), + ThreadItem::Reasoning { + summary, content, .. + } => { + if from_replay { + for delta in summary { + self.on_agent_reasoning_delta(delta); + } + if self.config.show_raw_agent_reasoning { + for delta in content { + self.on_agent_reasoning_delta(delta); + } + } + } + self.on_agent_reasoning_final(); + } + item @ ThreadItem::CommandExecution { + status: codex_app_server_protocol::CommandExecutionStatus::InProgress, + .. + } => self.on_command_execution_started(item), + item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item), + ThreadItem::FileChange { + status: codex_app_server_protocol::PatchApplyStatus::InProgress, + .. + } => {} + item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item), + item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item), + ThreadItem::WebSearch { id, query, action } => { + self.on_web_search_begin(id.clone()); + self.on_web_search_end( + id, + query, + action.unwrap_or(codex_app_server_protocol::WebSearchAction::Other), + ); + } + ThreadItem::ImageView { id: _, path } => { + self.on_view_image_tool_call(path); + } + ThreadItem::ImageGeneration { + id, + revised_prompt, + saved_path, + .. + } => { + self.on_image_generation_end(id, revised_prompt, saved_path); + } + ThreadItem::EnteredReviewMode { review, .. } => { + if from_replay { + self.enter_review_mode_with_hint(review, /*from_replay*/ true); + } + } + ThreadItem::ExitedReviewMode { .. } => { + self.exit_review_mode_after_item(); + } + ThreadItem::ContextCompaction { .. } => { + self.add_info_message("Context compacted".to_string(), /*hint*/ None); + } + ThreadItem::HookPrompt { .. } => {} + ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + }), + ThreadItem::DynamicToolCall { .. } => {} + } + + if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) && turn_id.is_empty() { + self.request_redraw(); + } + } +} diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index bc431d0db7..356e191b8e 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -94,7 +94,11 @@ impl ChatWidget { }) .collect(); - let view = SkillsToggleView::new(items, self.app_event_tx.clone()); + let view = SkillsToggleView::new( + items, + self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), + ); self.bottom_pane.show_view(Box::new(view)); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_hardcoded_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_hardcoded_only.snap index 652c914adf..10c1132c3f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_hardcoded_only.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_hardcoded_only.snap @@ -17,4 +17,4 @@ expression: status_line_popup_snapshot(&mut chat) [ ] current-dir Current working directory my-project · feat/awesome-feature · thread title - Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_live_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_live_only.snap index fc713c4cdc..3106adea2a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_live_only.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_live_only.snap @@ -17,4 +17,4 @@ expression: status_line_popup_snapshot(&mut chat) [ ] current-dir Current working directory preview-live-root · feature/live-preview-branch · Live preview thread - Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_mixed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_mixed.snap index 547f70daa8..1cb2dcb5af 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_mixed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_mixed.snap @@ -17,4 +17,4 @@ expression: status_line_popup_snapshot(&mut chat) [ ] current-dir Current working directory my-project · feature/mixed-preview · Mixed preview thread - Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap index cf2248ffdd..8ff4227504 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap @@ -17,4 +17,4 @@ expression: terminal_title_popup_snapshot(&mut chat) [ ] run-state Compact session run-state text (Ready, Working, Thinking) thread title | feat/awesome-feature | Tasks 0/0 - Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap index c22a70f714..8c149b8cc9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap @@ -17,4 +17,4 @@ expression: terminal_title_popup_snapshot(&mut chat) [ ] run-state Compact session run-state text (Ready, Working, Thinking) preview-live-root | Live preview thread | feature/live-preview-branch | Tasks 2/5 - Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap index 06b988811e..b04e07bd9b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap @@ -17,4 +17,4 @@ expression: terminal_title_popup_snapshot(&mut chat) [ ] git-branch Current Git branch (omitted when unavailable) project | Mixed preview thread | Tasks 0/0 - Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs new file mode 100644 index 0000000000..e6f5701d1c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -0,0 +1,459 @@ +//! Streaming transcript updates for `ChatWidget`. +//! +//! This module owns assistant, plan, and reasoning deltas, including stream-tail +//! cells, commit ticks, and interrupt deferral. + +use super::*; + +impl ChatWidget { + pub(super) fn restore_reasoning_status_header(&mut self) { + if let Some(header) = extract_first_bold(&self.reasoning_buffer) { + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; + self.set_status_header(header); + } else if self.bottom_pane.is_task_running() { + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; + self.set_status_header(String::from("Working")); + } + } + + pub(super) fn flush_answer_stream_with_separator(&mut self) { + let had_stream_controller = self.stream_controller.is_some(); + if let Some(mut controller) = self.stream_controller.take() { + let scrollback_reflow = if controller.has_live_tail() { + crate::app_event::ConsolidationScrollbackReflow::Required + } else { + crate::app_event::ConsolidationScrollbackReflow::IfResizeReflowRan + }; + self.clear_active_stream_tail(); + let (cell, source) = controller.finalize(); + let deferred_history_cell = + if scrollback_reflow == crate::app_event::ConsolidationScrollbackReflow::Required { + cell + } else { + if let Some(cell) = cell { + self.add_boxed_history(cell); + } + None + }; + // Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell + // that can re-render from source on resize. + if let Some(source) = source { + let source = parse_assistant_markdown(&source).visible_markdown; + self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { + source, + cwd: self.config.cwd.to_path_buf(), + scrollback_reflow, + deferred_history_cell, + }); + } + } + self.adaptive_chunking.reset(); + if had_stream_controller && self.stream_controllers_idle() { + self.app_event_tx.send(AppEvent::StopCommitAnimation); + } + } + + pub(super) fn stream_controllers_idle(&self) -> bool { + self.stream_controller + .as_ref() + .map(|controller| controller.queued_lines() == 0) + .unwrap_or(true) + && self + .plan_stream_controller + .as_ref() + .map(|controller| controller.queued_lines() == 0) + .unwrap_or(true) + } + + /// Restore the status indicator only after commentary completion is pending, + /// the turn is still running, and all stream queues have drained. + /// + /// This gate prevents flicker while normal output is still actively + /// streaming, but still restores a visible "working" affordance when a + /// commentary block ends before the turn itself has completed. + pub(super) fn maybe_restore_status_indicator_after_stream_idle(&mut self) { + if !self.status_state.pending_status_indicator_restore + || !self.bottom_pane.is_task_running() + || !self.stream_controllers_idle() + { + return; + } + + self.bottom_pane.ensure_status_indicator(); + self.set_status( + self.status_state.current_status.header.clone(), + self.status_state.current_status.details.clone(), + StatusDetailsCapitalization::Preserve, + self.status_state.current_status.details_max_lines, + ); + self.status_state.pending_status_indicator_restore = false; + } + + pub(super) fn finalize_completed_assistant_message(&mut self, message: Option<&str>) { + // If we have a stream_controller, the finalized message payload is redundant because the + // visible content has already been accumulated through deltas. + if self.stream_controller.is_none() + && let Some(message) = message + && !message.is_empty() + { + self.handle_streaming_delta(message.to_string()); + } + self.flush_answer_stream_with_separator(); + self.handle_stream_finished(); + self.request_redraw(); + } + + pub(super) fn on_agent_message_delta(&mut self, delta: String) { + self.handle_streaming_delta(delta); + } + + pub(super) fn on_plan_delta(&mut self, delta: String) { + if self.active_mode_kind() != ModeKind::Plan { + return; + } + if !self.transcript.plan_item_active { + self.transcript.plan_item_active = true; + self.transcript.plan_delta_buffer.clear(); + } + self.transcript.plan_delta_buffer.push_str(&delta); + if self.plan_stream_controller.is_none() { + // Before starting a plan stream, flush any active exec cell group. + self.flush_unified_exec_wait_streak(); + self.flush_active_cell(); + self.plan_stream_controller = Some(PlanStreamController::new( + self.current_stream_width(/*reserved_cols*/ 4), + &self.config.cwd, + self.history_render_mode(), + )); + } + if let Some(controller) = self.plan_stream_controller.as_mut() + && controller.push(&delta) + { + self.app_event_tx.send(AppEvent::StartCommitAnimation); + self.run_catch_up_commit_tick(); + } + self.sync_active_stream_tail(); + self.request_redraw(); + } + + pub(super) fn on_plan_item_completed(&mut self, text: String) { + let streamed_plan = self.transcript.plan_delta_buffer.trim().to_string(); + let plan_text = if text.trim().is_empty() { + streamed_plan + } else { + text + }; + if !plan_text.trim().is_empty() { + self.record_agent_markdown(&plan_text); + self.transcript.latest_proposed_plan_markdown = Some(plan_text.clone()); + } + // Plan commit ticks can hide the status row; remember whether we streamed plan output so + // completion can restore it once stream queues are idle. + let should_restore_after_stream = self.plan_stream_controller.is_some(); + self.transcript.plan_delta_buffer.clear(); + self.transcript.plan_item_active = false; + self.transcript.saw_plan_item_this_turn = true; + let (finalized_streamed_cell, consolidated_plan_source) = + if let Some(mut controller) = self.plan_stream_controller.take() { + let had_live_tail = controller.has_live_tail(); + self.clear_active_stream_tail(); + let (cell, source) = controller.finalize(); + if had_live_tail { + (None, source) + } else { + (cell, source) + } + } else { + (None, None) + }; + if let Some(cell) = finalized_streamed_cell { + self.add_boxed_history(cell); + // TODO: Replace streamed output with the final plan item text if plan streaming is + // removed or if we need to reconcile mismatches between streamed and final content. + if let Some(source) = consolidated_plan_source { + self.app_event_tx + .send(AppEvent::ConsolidateProposedPlan(source)); + } + } else if !plan_text.is_empty() { + self.add_to_history(history_cell::new_proposed_plan(plan_text, &self.config.cwd)); + } else if let Some(source) = consolidated_plan_source { + self.app_event_tx + .send(AppEvent::ConsolidateProposedPlan(source)); + } + if should_restore_after_stream { + self.status_state.pending_status_indicator_restore = true; + self.maybe_restore_status_indicator_after_stream_idle(); + } + } + + pub(super) fn on_agent_reasoning_delta(&mut self, delta: String) { + // For reasoning deltas, do not stream to history. Accumulate the + // current reasoning block and extract the first bold element + // (between **/**) as the chunk header. Show this header as status. + self.reasoning_buffer.push_str(&delta); + + if self.unified_exec_wait_streak.is_some() { + // Unified exec waiting should take precedence over reasoning-derived status headers. + self.request_redraw(); + return; + } + + if let Some(header) = extract_first_bold(&self.reasoning_buffer) { + // Update the shimmer header to the extracted reasoning chunk header. + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; + self.set_status_header(header); + } else { + // Fallback while we don't yet have a bold header: leave existing header as-is. + } + self.request_redraw(); + } + + pub(super) fn on_agent_reasoning_final(&mut self) { + // At the end of a reasoning block, record transcript-only content. + self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + if !self.full_reasoning_buffer.is_empty() { + let cell = history_cell::new_reasoning_summary_block( + self.full_reasoning_buffer.clone(), + &self.config.cwd, + ); + self.add_boxed_history(cell); + } + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.request_redraw(); + } + + pub(super) fn on_reasoning_section_break(&mut self) { + // Start a new reasoning block for header extraction and accumulate transcript. + self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + self.full_reasoning_buffer.push_str("\n\n"); + self.reasoning_buffer.clear(); + } + + pub(super) fn on_stream_error(&mut self, message: String, additional_details: Option) { + self.status_state.remember_retry_status_header(); + self.bottom_pane.ensure_status_indicator(); + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; + self.set_status( + message, + additional_details, + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); + } + + /// Handle completion of an `AgentMessage` turn item. + /// + /// Commentary completion sets a deferred restore flag so the status row + /// returns once stream queues are idle. Final-answer completion (or absent + /// phase for legacy models) clears the flag to preserve historical behavior. + pub(super) fn on_agent_message_item_completed( + &mut self, + item: AgentMessageItem, + from_replay: bool, + ) { + let mut message = String::new(); + for content in &item.content { + match content { + AgentMessageContent::Text { text } => message.push_str(text), + } + } + let parsed = parse_assistant_markdown(&message); + self.finalize_completed_assistant_message( + (!parsed.visible_markdown.is_empty()).then_some(parsed.visible_markdown.as_str()), + ); + if matches!(item.phase, Some(MessagePhase::FinalAnswer) | None) + && !parsed.visible_markdown.is_empty() + { + self.record_agent_markdown(&parsed.visible_markdown); + } + if !from_replay + && let Some(cwd) = parsed.last_created_branch_cwd() + && let Some(thread_id) = self.thread_id + && let Some(runner) = self.workspace_command_runner.clone() + { + let cwd = PathBuf::from(cwd); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + if let Some(branch) = + crate::branch_summary::current_branch_name(runner.as_ref(), &cwd).await + { + tx.send(AppEvent::SyncThreadGitBranch { thread_id, branch }); + } + }); + } + self.status_state.pending_status_indicator_restore = match item.phase { + // Models that don't support preambles only output AgentMessageItems on turn completion. + Some(MessagePhase::FinalAnswer) | None => !self.input_queue.pending_steers.is_empty(), + Some(MessagePhase::Commentary) => true, + }; + self.maybe_restore_status_indicator_after_stream_idle(); + } + + /// Periodic tick for stream commits. In smooth mode this preserves one-line pacing, while + /// catch-up mode drains larger batches to reduce queue lag. + pub(crate) fn on_commit_tick(&mut self) { + self.run_commit_tick(); + } + + /// Runs a regular periodic commit tick. + pub(super) fn run_commit_tick(&mut self) { + self.run_commit_tick_with_scope(CommitTickScope::AnyMode); + } + + /// Runs an opportunistic commit tick only if catch-up mode is active. + pub(super) fn run_catch_up_commit_tick(&mut self) { + self.run_commit_tick_with_scope(CommitTickScope::CatchUpOnly); + } + + /// Runs a commit tick for the current stream queue snapshot. + /// + /// `scope` controls whether this call may commit in smooth mode or only when catch-up + /// is currently active. While lines are actively streaming we hide the status row to avoid + /// duplicate "in progress" affordances. Restoration is gated separately so we only re-show + /// the row after commentary completion once stream queues are idle. + pub(super) fn run_commit_tick_with_scope(&mut self, scope: CommitTickScope) { + let now = Instant::now(); + let outcome = run_commit_tick( + &mut self.adaptive_chunking, + self.stream_controller.as_mut(), + self.plan_stream_controller.as_mut(), + scope, + now, + ); + for cell in outcome.cells { + self.bottom_pane.hide_status_indicator(); + self.add_boxed_history(cell); + } + self.sync_active_stream_tail(); + + if outcome.has_controller && outcome.all_idle { + self.maybe_restore_status_indicator_after_stream_idle(); + self.app_event_tx.send(AppEvent::StopCommitAnimation); + } + + if self.turn_lifecycle.agent_turn_running { + self.refresh_runtime_metrics(); + } + } + + pub(super) fn flush_interrupt_queue(&mut self) { + let mut mgr = std::mem::take(&mut self.interrupts); + mgr.flush_all(self); + self.interrupts = mgr; + } + + #[inline] + pub(super) fn defer_or_handle( + &mut self, + push: impl FnOnce(&mut InterruptManager), + handle: impl FnOnce(&mut Self), + ) { + // Preserve deterministic FIFO across queued interrupts: once anything + // is queued due to an active write cycle, continue queueing until the + // queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin). + if self.stream_controller.is_some() || !self.interrupts.is_empty() { + push(&mut self.interrupts); + } else { + handle(self); + } + } + + pub(super) fn handle_stream_finished(&mut self) { + if self.task_complete_pending { + self.bottom_pane.hide_status_indicator(); + self.task_complete_pending = false; + } + // A completed stream indicates non-exec content was just inserted. + self.flush_interrupt_queue(); + } + + #[inline] + pub(super) fn handle_streaming_delta(&mut self, delta: String) { + if self.stream_controller.is_none() { + // Before starting an agent stream, flush any active exec cell group. + self.flush_unified_exec_wait_streak(); + self.flush_active_cell(); + // If the previous turn inserted non-stream history (exec output, patch status, MCP + // calls), render a separator before starting the next streamed assistant message. + if self.transcript.needs_final_message_separator && self.transcript.had_work_activity { + self.add_to_history(history_cell::FinalMessageSeparator::new( + /*elapsed_seconds*/ None, /*runtime_metrics*/ None, + )); + self.transcript.needs_final_message_separator = false; + } else if self.transcript.needs_final_message_separator { + // Reset the flag even if we don't show separator (no work was done) + self.transcript.needs_final_message_separator = false; + } + self.stream_controller = Some(StreamController::new( + self.current_stream_width(/*reserved_cols*/ 2), + &self.config.cwd, + self.history_render_mode(), + )); + } + if let Some(controller) = self.stream_controller.as_mut() + && controller.push(&delta) + { + self.app_event_tx.send(AppEvent::StartCommitAnimation); + self.run_catch_up_commit_tick(); + } + self.sync_active_stream_tail(); + self.request_redraw(); + } + + pub(super) fn active_cell_is_stream_tail(&self) -> bool { + self.transcript.active_cell.as_ref().is_some_and(|cell| { + cell.as_any().is::() + || cell.as_any().is::() + }) + } + + pub(super) fn has_active_stream_tail(&self) -> bool { + (self.stream_controller.is_some() || self.plan_stream_controller.is_some()) + && self.active_cell_is_stream_tail() + } + + pub(super) fn sync_active_stream_tail(&mut self) { + if let Some(controller) = self.stream_controller.as_ref() { + let tail_lines = controller.current_tail_lines(); + if tail_lines.is_empty() { + self.clear_active_stream_tail(); + return; + } + + self.bottom_pane.hide_status_indicator(); + self.transcript.active_cell = + Some(Box::new(history_cell::StreamingAgentTailCell::new( + tail_lines, + controller.tail_starts_stream(), + ))); + self.bump_active_cell_revision(); + return; + } + + if let Some(controller) = self.plan_stream_controller.as_ref() { + let tail_lines = controller.current_tail_display_lines(); + if tail_lines.is_empty() { + self.clear_active_stream_tail(); + return; + } + + self.bottom_pane.hide_status_indicator(); + self.transcript.active_cell = Some(Box::new(history_cell::StreamingPlanTailCell::new( + tail_lines, + !controller.tail_starts_stream(), + ))); + self.bump_active_cell_revision(); + return; + } + + self.clear_active_stream_tail(); + } + + pub(super) fn clear_active_stream_tail(&mut self) { + if self.active_cell_is_stream_tail() { + self.transcript.active_cell = None; + self.bump_active_cell_revision(); + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 83c212c307..95e8317e9c 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; use codex_app_server_protocol::PermissionProfileFileSystemPermissions; use codex_app_server_protocol::PermissionProfileNetworkPermissions; use pretty_assertions::assert_eq; +use std::collections::VecDeque; #[tokio::test] async fn submission_preserves_text_elements_and_local_images() { diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index f5007e3faf..28ed69139b 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1315,6 +1315,7 @@ pub(super) fn plugins_test_summary( PluginSummary { id: id.to_string(), remote_plugin_id: None, + local_version: None, name: name.to_string(), share_context: None, source: PluginSource::Local { 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 a2dd3b9955..4f4398d349 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2181,7 +2181,11 @@ async fn experimental_features_popup_snapshot() { enabled: true, }, ]; - let view = ExperimentalFeaturesView::new(features, chat.app_event_tx.clone()); + let view = ExperimentalFeaturesView::new( + features, + chat.app_event_tx.clone(), + crate::keymap::RuntimeKeymap::defaults().list, + ); chat.bottom_pane.show_view(Box::new(view)); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -2201,6 +2205,7 @@ async fn experimental_features_toggle_saves_on_exit() { enabled: false, }], chat.app_event_tx.clone(), + crate::keymap::RuntimeKeymap::defaults().list, ); chat.bottom_pane.show_view(Box::new(view)); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 18798fe5a5..8c16e0b929 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -1,5 +1,6 @@ use super::*; use pretty_assertions::assert_eq; +use std::collections::VecDeque; #[tokio::test] async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { 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 6cc9abc704..4cc01d7591 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -401,6 +401,7 @@ async fn completed_plan_table_tail_skips_provisional_history_insert() { } #[tokio::test] +#[cfg_attr(target_os = "windows", ignore = "disabled on windows")] async fn configured_pet_load_is_deferred_until_after_construction() { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); @@ -434,7 +435,7 @@ async fn configured_pet_load_is_deferred_until_after_construction() { let chat = ChatWidget::new_with_app_event(init); assert!(!chat.ambient_pet_image_enabled()); - let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + let event = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), rx.recv()) .await .unwrap() .unwrap(); @@ -796,57 +797,9 @@ async fn rate_limit_switch_prompt_popup_snapshot() { assert_chatwidget_snapshot!("rate_limit_switch_prompt_popup", popup); } -#[tokio::test] -async fn workspace_owner_usage_nudge_flag_disabled_keeps_generic_rate_limit_error() { - { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let mut limits = snapshot(/*percent*/ 100.0); - limits.rate_limit_reached_type = - Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); - chat.on_rate_limit_snapshot(Some(limits)); - - chat.on_rate_limit_error( - RateLimitErrorKind::UsageLimit, - "Usage limit reached.".to_string(), - ); - let rendered = drain_insert_history(&mut rx) - .into_iter() - .map(|lines| lines_to_single_string(&lines)) - .collect::(); - assert!( - rendered.contains("Usage limit reached."), - "rendered: {rendered}" - ); - } - - { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let mut limits = snapshot(/*percent*/ 100.0); - limits.rate_limit_reached_type = - Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); - chat.on_rate_limit_snapshot(Some(limits)); - - chat.on_rate_limit_error( - RateLimitErrorKind::UsageLimit, - "Usage limit reached.".to_string(), - ); - let popup = render_bottom_popup(&chat, /*width*/ 100); - assert!( - !popup.contains("Request a limit increase from your owner"), - "popup: {popup}" - ); - assert_no_owner_nudge_or_rate_limit_refresh(&mut rx); - } -} - -fn enable_workspace_owner_usage_nudge(chat: &mut ChatWidget) { - chat.set_feature_enabled(Feature::WorkspaceOwnerUsageNudge, /*enabled*/ true); -} - #[tokio::test] async fn workspace_member_credits_depleted_prompts_and_sends_credits() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - enable_workspace_owner_usage_nudge(&mut chat); let mut limits = snapshot(/*percent*/ 100.0); limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); chat.on_rate_limit_snapshot(Some(limits)); @@ -866,7 +819,6 @@ async fn workspace_member_credits_depleted_prompts_and_sends_credits() { #[tokio::test] async fn workspace_member_usage_limit_prompts_and_sends_usage_limit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - enable_workspace_owner_usage_nudge(&mut chat); let mut limits = snapshot(/*percent*/ 100.0); limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); chat.on_rate_limit_snapshot(Some(limits)); @@ -886,7 +838,6 @@ async fn workspace_member_usage_limit_prompts_and_sends_usage_limit() { #[tokio::test] async fn header_rate_limit_snapshot_preserves_member_limit_type_for_error_prompt() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - enable_workspace_owner_usage_nudge(&mut chat); let mut usage_limits = snapshot(/*percent*/ 100.0); usage_limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); @@ -916,7 +867,6 @@ async fn header_rate_limit_snapshot_preserves_member_limit_type_for_error_prompt #[tokio::test] async fn usage_limit_error_remaps_stale_member_credits_state_to_usage_limit_prompt() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - enable_workspace_owner_usage_nudge(&mut chat); let mut limits = snapshot(/*percent*/ 100.0); limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); chat.on_rate_limit_snapshot(Some(limits)); @@ -953,7 +903,6 @@ async fn workspace_owner_limit_states_do_not_prompt_for_owner_nudge() { ), ] { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - enable_workspace_owner_usage_nudge(&mut chat); let mut limits = snapshot(/*percent*/ 100.0); limits.rate_limit_reached_type = Some(limit_type); chat.on_rate_limit_snapshot(Some(limits)); @@ -983,7 +932,6 @@ async fn workspace_owner_limit_states_render_state_specific_messages() { let mut rendered_cases = Vec::new(); for (limit_type, error_kind, expected) in cases { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - enable_workspace_owner_usage_nudge(&mut chat); let mut limits = snapshot(/*percent*/ 100.0); limits.rate_limit_reached_type = Some(limit_type); chat.on_rate_limit_snapshot(Some(limits)); @@ -1006,7 +954,6 @@ async fn workspace_owner_limit_states_render_state_specific_messages() { #[tokio::test] async fn missing_rate_limit_reached_type_does_not_prompt_or_refresh() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - enable_workspace_owner_usage_nudge(&mut chat); chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 100.0))); chat.on_rate_limit_error( @@ -1021,7 +968,6 @@ async fn missing_rate_limit_reached_type_does_not_prompt_or_refresh() { #[tokio::test] async fn workspace_owner_nudge_default_no_dismisses_without_sending() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - enable_workspace_owner_usage_nudge(&mut chat); let mut limits = snapshot(/*percent*/ 100.0); limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); chat.on_rate_limit_snapshot(Some(limits)); @@ -1038,7 +984,6 @@ async fn workspace_owner_nudge_default_no_dismisses_without_sending() { #[tokio::test] async fn workspace_owner_nudge_reappears_after_dismissing_no() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - enable_workspace_owner_usage_nudge(&mut chat); let mut limits = snapshot(/*percent*/ 100.0); limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); chat.on_rate_limit_snapshot(Some(limits)); @@ -1081,7 +1026,6 @@ async fn workspace_owner_credits_nudge_completion_renders_feedback() { let mut rendered_cases = Vec::new(); for (result, expected) in cases { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - enable_workspace_owner_usage_nudge(&mut chat); chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits); chat.finish_add_credits_nudge_email_request(result); let rendered = drain_insert_history(&mut rx) @@ -1118,7 +1062,6 @@ async fn workspace_owner_usage_limit_nudge_completion_renders_feedback() { let mut rendered_cases = Vec::new(); for (result, expected) in cases { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - enable_workspace_owner_usage_nudge(&mut chat); chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit); chat.finish_add_credits_nudge_email_request(result); let rendered = drain_insert_history(&mut rx) diff --git a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs new file mode 100644 index 0000000000..456e064caa --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs @@ -0,0 +1,264 @@ +//! Non-command tool lifecycle rendering for `ChatWidget`. +//! +//! This module handles patch, MCP, web search, image, and collaborator tool +//! events as transcript cells. + +use super::*; + +impl ChatWidget { + pub(super) fn on_patch_apply_begin(&mut self, changes: HashMap) { + self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd)); + } + + pub(super) fn on_view_image_tool_call(&mut self, path: AbsolutePathBuf) { + self.flush_answer_stream_with_separator(); + self.add_to_history(history_cell::new_view_image_tool_call( + path, + &self.config.cwd, + )); + self.request_redraw(); + } + + pub(super) fn on_image_generation_begin(&mut self) { + self.flush_answer_stream_with_separator(); + } + + pub(super) fn on_image_generation_end( + &mut self, + call_id: String, + revised_prompt: Option, + saved_path: Option, + ) { + self.flush_answer_stream_with_separator(); + self.add_to_history(history_cell::new_image_generation_call( + call_id, + revised_prompt, + saved_path, + )); + self.request_redraw(); + } + + pub(super) fn on_file_change_completed(&mut self, item: ThreadItem) { + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_completed(item), + |s| s.handle_file_change_completed_now(item2), + ); + } + + pub(super) fn on_mcp_tool_call_started(&mut self, item: ThreadItem) { + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_started(item), + |s| s.handle_mcp_tool_call_started_now(item2), + ); + } + + pub(super) fn on_mcp_tool_call_completed(&mut self, item: ThreadItem) { + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_completed(item), + |s| s.handle_mcp_tool_call_completed_now(item2), + ); + } + + pub(super) fn on_web_search_begin(&mut self, call_id: String) { + self.flush_answer_stream_with_separator(); + self.flush_active_cell(); + self.transcript.active_cell = Some(Box::new(history_cell::new_active_web_search_call( + call_id, + String::new(), + self.config.animations, + ))); + self.bump_active_cell_revision(); + self.request_redraw(); + } + + pub(super) fn on_web_search_end( + &mut self, + call_id: String, + query: String, + action: codex_app_server_protocol::WebSearchAction, + ) { + self.flush_answer_stream_with_separator(); + let mut handled = false; + if let Some(cell) = self + .transcript + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + && cell.call_id() == call_id + { + cell.update(action.clone(), query.clone()); + cell.complete(); + self.bump_active_cell_revision(); + self.flush_active_cell(); + handled = true; + } + + if !handled { + self.add_to_history(history_cell::new_web_search_call(call_id, query, action)); + } + self.transcript.had_work_activity = true; + } + + pub(super) fn on_collab_event(&mut self, cell: PlainHistoryCell) { + self.flush_answer_stream_with_separator(); + self.add_to_history(cell); + self.request_redraw(); + } + + pub(super) fn on_collab_agent_tool_call(&mut self, item: ThreadItem) { + let ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } = &item + else { + return; + }; + if matches!(tool, CollabAgentTool::SpawnAgent) + && let Some(spawn_request) = multi_agents::spawn_request_summary(&item) + { + self.pending_collab_spawn_requests + .insert(id.clone(), spawn_request); + } + + let cached_spawn_request = if matches!(tool, CollabAgentTool::SpawnAgent) + && !matches!(status, CollabAgentToolCallStatus::InProgress) + { + self.pending_collab_spawn_requests.remove(id) + } else { + None + }; + + if let Some(cell) = multi_agents::tool_call_history_cell( + &item, + cached_spawn_request.as_ref(), + |thread_id| self.collab_agent_metadata(thread_id), + ) { + self.on_collab_event(cell); + } + } + + pub(crate) fn handle_file_change_completed_now(&mut self, item: ThreadItem) { + let ThreadItem::FileChange { status, .. } = item else { + return; + }; + // If the patch was successful, just let the "Edited" block stand. + // Otherwise, add a failure block. + if matches!(status, codex_app_server_protocol::PatchApplyStatus::Failed) { + self.add_to_history(history_cell::new_patch_apply_failure(String::new())); + } + // Mark that actual work was done (patch applied) + self.transcript.had_work_activity = true; + } + + pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItem) { + let ThreadItem::McpToolCall { + id, + server, + tool, + arguments, + .. + } = item + else { + return; + }; + self.flush_answer_stream_with_separator(); + self.flush_active_cell(); + self.transcript.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( + id, + McpInvocation { + server, + tool, + arguments: Some(arguments), + }, + self.config.animations, + ))); + self.bump_active_cell_revision(); + self.request_redraw(); + } + + pub(crate) fn handle_mcp_tool_call_completed_now(&mut self, item: ThreadItem) { + self.flush_answer_stream_with_separator(); + + let ThreadItem::McpToolCall { + id, + server, + tool, + arguments, + result, + error, + duration_ms, + .. + } = item + else { + return; + }; + let invocation = McpInvocation { + server, + tool, + arguments: Some(arguments), + }; + let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); + let result = match (result, error) { + (_, Some(error)) => Err(error.message), + (Some(result), None) => { + let result = *result; + Ok(codex_protocol::mcp::CallToolResult { + content: result.content, + structured_content: result.structured_content, + is_error: Some(false), + meta: None, + }) + } + (None, None) => Err("MCP tool call completed without a result".to_string()), + }; + + let extra_cell = match self + .transcript + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + { + Some(cell) if cell.call_id() == id => cell.complete(duration, result), + _ => { + self.flush_active_cell(); + let mut cell = + history_cell::new_active_mcp_tool_call(id, invocation, self.config.animations); + let extra_cell = cell.complete(duration, result); + self.transcript.active_cell = Some(Box::new(cell)); + extra_cell + } + }; + + self.flush_active_cell(); + if let Some(extra) = extra_cell { + self.add_boxed_history(extra); + } + // Mark that actual work was done (MCP tool call) + self.transcript.had_work_activity = true; + } + + pub(crate) fn handle_queued_item_started_now(&mut self, item: ThreadItem) { + match item { + item @ ThreadItem::CommandExecution { .. } => { + self.handle_command_execution_started_now(item); + } + item @ ThreadItem::McpToolCall { .. } => { + self.handle_mcp_tool_call_started_now(item); + } + _ => {} + } + } + + pub(crate) fn handle_queued_item_completed_now(&mut self, item: ThreadItem) { + match item { + item @ ThreadItem::CommandExecution { .. } => { + self.handle_command_execution_completed_now(item); + } + item @ ThreadItem::FileChange { .. } => self.handle_file_change_completed_now(item), + item @ ThreadItem::McpToolCall { .. } => self.handle_mcp_tool_call_completed_now(item), + _ => {} + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs new file mode 100644 index 0000000000..7ec567325b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -0,0 +1,449 @@ +//! Interactive tool request surfaces for `ChatWidget`. +//! +//! This module owns approval, permission, elicitation, and user-input prompts +//! that block on user decisions. + +use super::*; + +impl ChatWidget { + pub(super) fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_exec_approval(ev), + |s| s.handle_exec_approval_now(ev2), + ); + } + + pub(super) fn on_apply_patch_approval_request( + &mut self, + _id: String, + ev: ApplyPatchApprovalRequestEvent, + ) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_apply_patch_approval(ev), + |s| s.handle_apply_patch_approval_now(ev2), + ); + } + + /// Handle guardian review lifecycle events for the current thread. + /// + /// In-progress assessments temporarily own the live status footer so the + /// user can see what is being reviewed, including parallel review + /// aggregation. Terminal assessments clear or update that footer state and + /// render the final approved/denied history cell when guardian returns a + /// decision. + pub(super) fn on_guardian_assessment(&mut self, ev: GuardianAssessmentEvent) { + let permission_request_summary = |subject: &str, reason: &Option| { + reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .map(|reason| format!("{subject}: {reason}")) + .unwrap_or_else(|| subject.to_string()) + }; + let guardian_action_summary = |action: &GuardianAssessmentAction| match action { + GuardianAssessmentAction::Command { command, .. } => Some(command.clone()), + GuardianAssessmentAction::Execve { program, argv, .. } => { + let command = if argv.is_empty() { + vec![program.clone()] + } else { + argv.clone() + }; + shlex::try_join(command.iter().map(String::as_str)) + .ok() + .or_else(|| Some(command.join(" "))) + } + GuardianAssessmentAction::ApplyPatch { files, .. } => Some(if files.len() == 1 { + format!("apply_patch touching {}", files[0].display()) + } else { + format!("apply_patch touching {} files", files.len()) + }), + GuardianAssessmentAction::NetworkAccess { target, .. } => { + Some(format!("network access to {target}")) + } + GuardianAssessmentAction::McpToolCall { + server, + tool_name, + connector_name, + .. + } => { + let label = connector_name.as_deref().unwrap_or(server.as_str()); + Some(format!("MCP {tool_name} on {label}")) + } + GuardianAssessmentAction::RequestPermissions { reason, .. } => { + Some(permission_request_summary("permission request", reason)) + } + }; + let guardian_command = |action: &GuardianAssessmentAction| match action { + GuardianAssessmentAction::Command { command, .. } => shlex::split(command) + .filter(|command| !command.is_empty()) + .or_else(|| Some(vec![command.clone()])), + GuardianAssessmentAction::Execve { program, argv, .. } => Some(if argv.is_empty() { + vec![program.clone()] + } else { + argv.clone() + }) + .filter(|command| !command.is_empty()), + GuardianAssessmentAction::ApplyPatch { .. } + | GuardianAssessmentAction::NetworkAccess { .. } + | GuardianAssessmentAction::McpToolCall { .. } + | GuardianAssessmentAction::RequestPermissions { .. } => None, + }; + + if ev.status == GuardianAssessmentStatus::InProgress + && let Some(detail) = guardian_action_summary(&ev.action) + { + // In-progress assessments own the live footer state while the + // review is pending. Parallel reviews are aggregated into one + // footer summary by `PendingGuardianReviewStatus`. + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane + .set_interrupt_hint_visible(/*visible*/ true); + self.status_state + .pending_guardian_review_status + .start_or_update(ev.id.clone(), detail); + if let Some(status) = self + .status_state + .pending_guardian_review_status + .status_indicator_state() + { + self.set_status( + status.header, + status.details, + StatusDetailsCapitalization::Preserve, + status.details_max_lines, + ); + } + self.request_redraw(); + return; + } + + // Terminal assessments remove the matching pending footer entry first, + // then render the final approved/denied history cell below. + if self + .status_state + .pending_guardian_review_status + .finish(&ev.id) + { + if let Some(status) = self + .status_state + .pending_guardian_review_status + .status_indicator_state() + { + self.set_status( + status.header, + status.details, + StatusDetailsCapitalization::Preserve, + status.details_max_lines, + ); + } else if self.status_state.current_status.is_guardian_review() { + self.set_status_header(String::from("Working")); + } + } else if self.status_state.pending_guardian_review_status.is_empty() + && self.status_state.current_status.is_guardian_review() + { + self.set_status_header(String::from("Working")); + } + + if ev.status == GuardianAssessmentStatus::Approved { + let cell = if let Some(command) = guardian_command(&ev.action) { + history_cell::new_approval_decision_cell( + command, + crate::history_cell::ReviewDecision::Approved, + history_cell::ApprovalDecisionActor::Guardian, + ) + } else if let Some(summary) = guardian_action_summary(&ev.action) { + history_cell::new_guardian_approved_action_request(summary) + } else { + let summary = serde_json::to_string(&ev.action) + .unwrap_or_else(|_| "".to_string()); + history_cell::new_guardian_approved_action_request(summary) + }; + + self.add_boxed_history(cell); + self.request_redraw(); + return; + } + + if ev.status == GuardianAssessmentStatus::TimedOut { + let cell = if let Some(command) = guardian_command(&ev.action) { + history_cell::new_approval_decision_cell( + command, + crate::history_cell::ReviewDecision::TimedOut, + history_cell::ApprovalDecisionActor::Guardian, + ) + } else { + match &ev.action { + GuardianAssessmentAction::ApplyPatch { files, .. } => { + let files = files + .iter() + .map(|path| path.display().to_string()) + .collect::>(); + history_cell::new_guardian_timed_out_patch_request(files) + } + GuardianAssessmentAction::McpToolCall { + server, tool_name, .. + } => history_cell::new_guardian_timed_out_action_request(format!( + "codex could call MCP tool {server}.{tool_name}" + )), + GuardianAssessmentAction::NetworkAccess { target, .. } => { + history_cell::new_guardian_timed_out_action_request(format!( + "codex could access {target}" + )) + } + GuardianAssessmentAction::RequestPermissions { reason, .. } => { + history_cell::new_guardian_timed_out_action_request( + permission_request_summary("codex could request permissions", reason), + ) + } + GuardianAssessmentAction::Command { .. } => unreachable!(), + GuardianAssessmentAction::Execve { .. } => unreachable!(), + } + }; + + self.add_boxed_history(cell); + self.request_redraw(); + return; + } + + if ev.status != GuardianAssessmentStatus::Denied { + return; + } + self.review.recent_auto_review_denials.push(ev.clone()); + let cell = if let Some(command) = guardian_command(&ev.action) { + history_cell::new_approval_decision_cell( + command, + crate::history_cell::ReviewDecision::Denied, + history_cell::ApprovalDecisionActor::Guardian, + ) + } else { + match &ev.action { + GuardianAssessmentAction::ApplyPatch { files, .. } => { + let files = files + .iter() + .map(|path| path.display().to_string()) + .collect::>(); + history_cell::new_guardian_denied_patch_request(files) + } + GuardianAssessmentAction::McpToolCall { + server, tool_name, .. + } => history_cell::new_guardian_denied_action_request(format!( + "codex to call MCP tool {server}.{tool_name}" + )), + GuardianAssessmentAction::NetworkAccess { target, .. } => { + history_cell::new_guardian_denied_action_request(format!( + "codex to access {target}" + )) + } + GuardianAssessmentAction::RequestPermissions { reason, .. } => { + history_cell::new_guardian_denied_action_request(permission_request_summary( + "codex to request permissions", + reason, + )) + } + GuardianAssessmentAction::Command { .. } => unreachable!(), + GuardianAssessmentAction::Execve { .. } => unreachable!(), + } + }; + + self.add_boxed_history(cell); + self.request_redraw(); + } + + pub(super) fn on_elicitation_request( + &mut self, + request_id: AppServerRequestId, + params: McpServerElicitationRequestParams, + ) { + let request_id2 = request_id.clone(); + let params2 = params.clone(); + self.defer_or_handle( + |q| q.push_elicitation(request_id, params), + |s| s.handle_elicitation_request_now(request_id2, params2), + ); + } + + pub(super) fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_user_input(ev), + |s| s.handle_request_user_input_now(ev2), + ); + } + + pub(super) fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_request_permissions(ev), + |s| s.handle_request_permissions_now(ev2), + ); + } + + pub(crate) fn handle_exec_approval_now(&mut self, ev: ExecApprovalRequestEvent) { + self.flush_answer_stream_with_separator(); + let command = shlex::try_join(ev.command.iter().map(String::as_str)) + .unwrap_or_else(|_| ev.command.join(" ")); + self.notify(Notification::ExecApprovalRequested { command }); + + let available_decisions = ev.effective_available_decisions(); + let request = ApprovalRequest::Exec { + thread_id: self.thread_id.unwrap_or_default(), + thread_label: None, + id: ev.effective_approval_id(), + command: ev.command, + reason: ev.reason, + available_decisions, + network_approval_context: ev.network_approval_context, + additional_permissions: ev.additional_permissions, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + } + + pub(crate) fn handle_apply_patch_approval_now(&mut self, ev: ApplyPatchApprovalRequestEvent) { + self.flush_answer_stream_with_separator(); + + let request = ApprovalRequest::ApplyPatch { + thread_id: self.thread_id.unwrap_or_default(), + thread_label: None, + id: ev.call_id, + reason: ev.reason, + changes: ev.changes.clone(), + cwd: self.config.cwd.clone(), + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + self.notify(Notification::EditApprovalRequested { + cwd: self.config.cwd.to_path_buf(), + changes: ev.changes.keys().cloned().collect(), + }); + } + + pub(crate) fn handle_elicitation_request_now( + &mut self, + request_id: AppServerRequestId, + params: McpServerElicitationRequestParams, + ) { + self.flush_answer_stream_with_separator(); + + self.notify(Notification::ElicitationRequested { + server_name: params.server_name.clone(), + }); + + let thread_id = self.thread_id.unwrap_or_default(); + if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request( + thread_id, + ¶ms.server_name, + request_id.clone(), + ¶ms.request, + ) { + self.open_app_link_view(params); + } else if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( + thread_id, + request_id.clone(), + params.clone(), + ) { + self.bottom_pane + .push_mcp_server_elicitation_request(request); + } else { + match params.request { + McpServerElicitationRequest::Form { message, .. } => { + let request = ApprovalRequest::McpElicitation { + thread_id, + thread_label: None, + server_name: params.server_name, + request_id, + message, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + } + McpServerElicitationRequest::Url { .. } => { + self.app_event_tx.resolve_elicitation( + thread_id, + params.server_name, + request_id, + codex_app_server_protocol::McpServerElicitationAction::Decline, + /*content*/ None, + /*meta*/ None, + ); + } + } + } + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + } + + pub(crate) fn push_approval_request(&mut self, request: ApprovalRequest) { + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + } + + pub(crate) fn push_mcp_server_elicitation_request( + &mut self, + request: McpServerElicitationFormRequest, + ) { + self.bottom_pane + .push_mcp_server_elicitation_request(request); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + } + + pub(crate) fn handle_request_user_input_now(&mut self, ev: ToolRequestUserInputParams) { + self.flush_answer_stream_with_separator(); + let question_count = ev.questions.len(); + let summary = Notification::user_input_request_summary(&ev.questions); + let title = match (question_count, summary.as_deref()) { + (1, Some(summary)) => summary.to_string(), + (1, None) => "Question requested".to_string(), + (count, _) => format!("{count} questions requested"), + }; + self.notify(Notification::PlanModePrompt { title }); + self.bottom_pane.push_user_input_request(ev); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + } + + pub(crate) fn handle_request_permissions_now(&mut self, ev: RequestPermissionsEvent) { + self.flush_answer_stream_with_separator(); + let request = ApprovalRequest::Permissions { + thread_id: self.thread_id.unwrap_or_default(), + thread_label: None, + call_id: ev.call_id, + reason: ev.reason, + permissions: ev.permissions, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + } +} diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs new file mode 100644 index 0000000000..077c33606d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -0,0 +1,477 @@ +//! Agent turn lifecycle and runtime bookkeeping for `ChatWidget`. +//! +//! This module owns task start/completion state, runtime metrics, plan updates, +//! and final-message separator handling. + +use super::*; + +impl ChatWidget { + /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. + /// + /// The bottom pane only has one running flag, but this module treats it as a derived state of + /// both the agent turn lifecycle and MCP startup lifecycle. + pub(super) fn update_task_running_state(&mut self) { + self.bottom_pane.set_task_running( + self.turn_lifecycle.agent_turn_running || self.mcp_startup_status.is_some(), + ); + self.refresh_plan_mode_nudge(); + self.refresh_status_surfaces(); + } + + pub(super) fn collect_runtime_metrics_delta(&mut self) { + if let Some(delta) = self.session_telemetry.runtime_metrics_summary() { + self.apply_runtime_metrics_delta(delta); + } + } + + pub(super) fn apply_runtime_metrics_delta(&mut self, delta: RuntimeMetricsSummary) { + let should_log_timing = has_websocket_timing_metrics(delta); + self.turn_runtime_metrics.merge(delta); + if should_log_timing { + self.log_websocket_timing_totals(delta); + } + } + + pub(super) fn log_websocket_timing_totals(&mut self, delta: RuntimeMetricsSummary) { + if let Some(label) = history_cell::runtime_metrics_label(delta.responses_api_summary()) { + self.add_plain_history_lines(vec![ + vec!["• ".dim(), format!("WebSocket timing: {label}").dark_gray()].into(), + ]); + } + } + + pub(super) fn refresh_runtime_metrics(&mut self) { + self.collect_runtime_metrics_delta(); + } + + // Raw reasoning uses the same flow as summarized reasoning + + pub(super) fn on_task_started(&mut self) { + self.input_queue.user_turn_pending_start = false; + self.turn_lifecycle.start(Instant::now()); + self.transcript.reset_turn_flags(); + self.adaptive_chunking.reset(); + self.plan_stream_controller = None; + self.turn_runtime_metrics = RuntimeMetricsSummary::default(); + self.session_telemetry.reset_runtime_metrics(); + self.bottom_pane.clear_quit_shortcut_hint(); + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + self.update_task_running_state(); + self.status_state.retry_status_header = None; + if self.active_hook_cell.take().is_some() { + self.bump_active_cell_revision(); + } + self.status_state.pending_status_indicator_restore = false; + self.bottom_pane + .set_interrupt_hint_visible(/*visible*/ true); + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; + self.set_status_header(String::from("Working")); + self.full_reasoning_buffer.clear(); + self.reasoning_buffer.clear(); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Running, + /*body*/ None, + ); + self.request_redraw(); + } + + pub(super) fn on_task_complete( + &mut self, + last_agent_message: Option, + duration_ms: Option, + from_replay: bool, + ) { + self.input_queue.submit_pending_steers_after_interrupt = false; + // Use `last_agent_message` from the turn-complete notification as the copy + // source only when no earlier item-level event (AgentMessageItem, plan + // commit, review output) already recorded markdown for this turn. This + // prevents the final summary from overwriting a more specific source. + let sanitized_last_agent_message = last_agent_message + .as_deref() + .map(|message| parse_assistant_markdown(message).visible_markdown); + if let Some(message) = sanitized_last_agent_message + .as_ref() + .filter(|message| !message.is_empty()) + && !self.transcript.saw_copy_source_this_turn + { + self.record_agent_markdown(message); + } + // For desktop notifications: prefer the notification payload, fall back to + // the item-level copy source if present, otherwise send an empty string. + let notification_response = sanitized_last_agent_message + .as_ref() + .filter(|message| !message.is_empty()) + .cloned() + .or_else(|| { + if self.transcript.saw_copy_source_this_turn { + self.transcript.last_agent_markdown.clone() + } else { + None + } + }) + .unwrap_or_default(); + self.transcript.saw_copy_source_this_turn = false; + // If a stream is currently active, finalize it. + self.flush_answer_stream_with_separator(); + if let Some(mut controller) = self.plan_stream_controller.take() { + let had_live_tail = controller.has_live_tail(); + self.clear_active_stream_tail(); + let (cell, source) = controller.finalize(); + if !had_live_tail && let Some(cell) = cell { + self.add_boxed_history(cell); + } + if let Some(source) = source { + self.app_event_tx + .send(AppEvent::ConsolidateProposedPlan(source)); + } + } + self.flush_unified_exec_wait_streak(); + if !from_replay { + self.collect_runtime_metrics_delta(); + let runtime_metrics = + (!self.turn_runtime_metrics.is_empty()).then_some(self.turn_runtime_metrics); + let show_work_separator = self.transcript.had_work_activity + && (self.transcript.needs_final_message_separator || runtime_metrics.is_some()); + if show_work_separator || runtime_metrics.is_some() { + let elapsed_seconds = if show_work_separator { + duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()) + .map(|duration_ms| duration_ms / 1_000) + .or_else(|| { + self.bottom_pane + .status_widget() + .map(crate::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds) + }) + } else { + None + }; + self.add_to_history(history_cell::FinalMessageSeparator::new( + elapsed_seconds, + runtime_metrics, + )); + } + self.turn_runtime_metrics = RuntimeMetricsSummary::default(); + self.transcript.needs_final_message_separator = false; + self.transcript.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.status_state.pending_status_indicator_restore = false; + self.input_queue.user_turn_pending_start = false; + self.turn_lifecycle.finish(); + self.update_task_running_state(); + self.running_commands.clear(); + self.suppressed_exec_calls.clear(); + self.last_unified_wait = None; + self.unified_exec_wait_streak = None; + if !from_replay { + let body = Notification::agent_turn_preview(¬ification_response); + self.set_ambient_pet_notification(crate::pets::PetNotificationKind::Review, body); + } + self.request_redraw(); + + let had_pending_steers = !self.input_queue.pending_steers.is_empty(); + self.refresh_pending_input_preview(); + + if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { + self.maybe_prompt_plan_implementation(); + } + // Keep this flag for replayed completion events so a subsequent live TurnComplete can + // still show the prompt once after thread switch replay. + if !from_replay { + self.transcript.saw_plan_item_this_turn = false; + } + // If there is a queued user message, send exactly one now to begin the next turn. + let follow_up_started = self.maybe_send_next_queued_input(); + let active_goal_continuing = self + .current_goal_status + .as_ref() + .is_some_and(GoalStatusState::is_active); + // Emit a notification when the agent is truly waiting for the user. + // Queued follow-up input and active goal continuation both start the + // next turn immediately, so notifying at that boundary would feel like + // a false "needs attention". + if !follow_up_started && !active_goal_continuing { + self.notify(Notification::AgentTurnComplete { + response: notification_response, + }); + } + + self.maybe_show_pending_rate_limit_prompt(); + } + + pub(super) fn maybe_prompt_plan_implementation(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + if self.has_queued_follow_up_messages() { + return; + } + if self.active_mode_kind() != ModeKind::Plan { + return; + } + if !self.transcript.saw_plan_item_this_turn { + return; + } + if !self.bottom_pane.no_modal_or_popup_active() { + return; + } + + if matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + ) { + return; + } + + self.open_plan_implementation_prompt(); + } + + pub(super) fn open_plan_implementation_prompt(&mut self) { + let default_mask = collaboration_modes::default_mode_mask(self.model_catalog.as_ref()); + let context_usage_label = self.plan_implementation_context_usage_label(); + + self.bottom_pane + .show_selection_view(plan_implementation::selection_view_params( + default_mask, + self.transcript.latest_proposed_plan_markdown.as_deref(), + context_usage_label.as_deref(), + )); + self.notify(Notification::PlanModePrompt { + title: PLAN_IMPLEMENTATION_TITLE.to_string(), + }); + } + + /// Returns a context-used label for the plan implementation prompt. + /// + /// The footer reports context remaining because it is ambient status, but + /// this prompt is asking whether to discard prior conversation state before + /// implementing a plan. Reporting used context makes the cleanup tradeoff + /// explicit. A fully fresh or unknown context window returns no label so + /// the clear-context option does not imply urgency without evidence. + pub(super) fn plan_implementation_context_usage_label(&self) -> Option { + let info = self.token_info.as_ref()?; + let percent = self.context_remaining_percent(info); + + let used_tokens = self.context_used_tokens(info, percent.is_some()); + if let Some(percent) = percent { + let used_percent = 100 - percent.clamp(0, 100); + if used_percent <= 0 { + return None; + } + return Some(format!("{used_percent}% used")); + } + + if let Some(tokens) = used_tokens + && tokens > 0 + { + return Some(format!("{} used", format_tokens_compact(tokens))); + } + + None + } + + pub(super) fn has_queued_follow_up_messages(&self) -> bool { + self.input_queue.has_queued_follow_up_messages() + } + + pub(super) fn handle_app_server_steer_rejected_error( + &mut self, + codex_error_info: &AppServerCodexErrorInfo, + ) -> bool { + matches!( + codex_error_info, + AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + + /// Finalize any active exec as failed and stop/clear agent-turn UI state. + /// + /// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup + /// and should continue to drive the bottom-pane running indicator while it is in progress. + pub(super) fn finalize_turn(&mut self) { + // Drop preview-only stream tail content on any termination path before + // failed-cell finalization, so transient tail cells are never persisted. + self.clear_active_stream_tail(); + // Ensure any spinner is replaced by a red ✗ and flushed into history. + self.finalize_active_cell_as_failed(); + // Turn-scoped hook rows are transient live state; once the turn is over, + // do not leave an orphaned running row behind if no matching completion + // event arrived before cancellation. + if self.active_hook_cell.take().is_some() { + self.bump_active_cell_revision(); + } + // Reset running state and clear streaming buffers. + self.input_queue.user_turn_pending_start = false; + self.turn_lifecycle.finish(); + self.update_task_running_state(); + self.running_commands.clear(); + self.suppressed_exec_calls.clear(); + self.last_unified_wait = None; + self.unified_exec_wait_streak = None; + self.adaptive_chunking.reset(); + self.stream_controller = None; + self.plan_stream_controller = None; + self.status_state.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(); + } + + pub(super) fn on_server_overloaded_error(&mut self, message: String) { + self.input_queue.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + + let message = if message.trim().is_empty() { + "Codex is currently experiencing high load.".to_string() + } else { + message + }; + + self.add_to_history(history_cell::new_warning_event(message)); + self.request_redraw(); + self.maybe_send_next_queued_input(); + } + + pub(super) fn on_error(&mut self, message: String) { + self.input_queue.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + self.add_to_history(history_cell::new_error_event(message)); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Failed, + /*body*/ None, + ); + self.request_redraw(); + + // After an error ends the turn, try sending the next queued input. + self.maybe_send_next_queued_input(); + } + + pub(super) fn on_cyber_policy_error(&mut self) { + self.input_queue.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + self.add_to_history(history_cell::new_cyber_policy_error_event()); + self.request_redraw(); + + // After an error ends the turn, try sending the next queued input. + self.maybe_send_next_queued_input(); + } + + pub(super) fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) { + let rate_limit_reached_type = self.codex_rate_limit_reached_type.map(|kind| { + if matches!(error_kind, RateLimitErrorKind::UsageLimit) { + match kind { + RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + RateLimitReachedType::WorkspaceOwnerUsageLimitReached + } + RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + RateLimitReachedType::WorkspaceMemberUsageLimitReached + } + other => other, + } + } else { + kind + } + }); + self.codex_rate_limit_reached_type = rate_limit_reached_type; + + match rate_limit_reached_type { + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => { + self.on_error( + "You're out of credits. Your workspace is out of credits. Add credits to continue using Codex." + .to_string(), + ); + } + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) => { + self.on_error( + "Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex." + .to_string(), + ); + } + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) => { + self.on_error(message); + self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::Credits); + } + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) => { + self.on_error(message); + self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::UsageLimit); + } + Some(RateLimitReachedType::RateLimitReached) | None => { + self.on_error(message); + } + } + } + + pub(super) fn handle_non_retry_error( + &mut self, + message: String, + codex_error_info: Option, + ) { + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) + { + } else if codex_error_info + .as_ref() + .is_some_and(is_app_server_cyber_policy_error) + { + self.on_cyber_policy_error(); + } else if let Some(info) = codex_error_info + .as_ref() + .and_then(app_server_rate_limit_error_kind) + { + match info { + RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message), + RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { + self.on_rate_limit_error(info, message) + } + } + } else { + self.on_error(message); + } + } + + pub(super) fn on_warning(&mut self, message: impl Into) { + let message = message.into(); + if !self.warning_display_state.should_display(&message) { + return; + } + self.add_to_history(history_cell::new_warning_event(message)); + self.request_redraw(); + } + + pub(super) fn on_app_server_model_verification( + &mut self, + verifications: &[AppServerModelVerification], + ) { + if verifications.contains(&AppServerModelVerification::TrustedAccessForCyber) { + self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING); + } + } + + pub(super) fn on_plan_update(&mut self, update: UpdatePlanArgs) { + self.transcript.saw_plan_update_this_turn = true; + let total = update.plan.len(); + let completed = update + .plan + .iter() + .filter(|item| match &item.status { + StepStatus::Completed => true, + StepStatus::Pending | StepStatus::InProgress => false, + }) + .count(); + self.transcript.last_plan_progress = (total > 0).then_some((completed, total)); + self.refresh_status_surfaces(); + self.add_to_history(history_cell::new_plan_update(update)); + } + + pub(super) fn interrupted_turn_message(&self, reason: TurnAbortReason) -> String { + if reason == TurnAbortReason::BudgetLimited { + return "Goal budget reached - the turn was stopped.".to_string(); + } + + "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_string() + } +} diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs index 9e84b8aa8b..bf3f9e8fe6 100644 --- a/codex-rs/tui/src/chatwidget/user_messages.rs +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -1,17 +1,496 @@ -//! User-message display models and helpers for the chat widget. +//! User-message models and helpers for the chat widget. //! //! The app-server preserves user input as structured chunks, while chat history -//! renders a single prompt row. This module owns that display projection and -//! the small compare key used to suppress duplicate rows for pending steers. +//! renders a single prompt row. This module owns the draft/message data models, +//! merge/remap behavior, display projection, and the small compare key used to +//! suppress duplicate rows for pending steers. +use std::collections::HashMap; +use std::collections::VecDeque; +use std::ops::Deref; use std::path::PathBuf; +use crate::bottom_pane::LocalImageAttachment; +use crate::bottom_pane::MentionBinding; +use crate::bottom_pane::QueuedInputAction; +use codex_app_server_protocol::TextElement as AppServerTextElement; use codex_app_server_protocol::UserInput; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; +use codex_protocol::models::local_image_label_text; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use super::ChatWidget; -use super::append_text_with_rebased_elements; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct UserMessage { + pub(super) text: String, + pub(super) local_images: Vec, + /// Remote image attachments represented as URLs (for example data URLs) + /// provided by app-server clients. + /// + /// Unlike `local_images`, these are not created by TUI image attach/paste + /// flows. The TUI can restore and remove them while editing/backtracking. + pub(super) remote_image_urls: Vec, + pub(super) text_elements: Vec, + pub(super) mention_bindings: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum UserMessageHistoryRecord { + UserMessageText, + Override(UserMessageHistoryOverride), +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct UserMessageHistoryOverride { + pub(super) text: String, + pub(super) text_elements: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ShellEscapePolicy { + Allow, + Disallow, +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct QueuedUserMessage { + pub(super) user_message: UserMessage, + pub(super) action: QueuedInputAction, +} + +impl QueuedUserMessage { + pub(super) fn new(user_message: UserMessage, action: QueuedInputAction) -> Self { + Self { + user_message, + action, + } + } + + pub(super) fn into_user_message(self) -> UserMessage { + self.user_message + } +} + +impl From for QueuedUserMessage { + fn from(user_message: UserMessage) -> Self { + Self::new(user_message, QueuedInputAction::Plain) + } +} + +impl Deref for QueuedUserMessage { + type Target = UserMessage; + + fn deref(&self) -> &Self::Target { + &self.user_message + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum QueueDrain { + Continue, + Stop, +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub(super) struct ThreadComposerState { + pub(super) text: String, + pub(super) local_images: Vec, + pub(super) remote_image_urls: Vec, + pub(super) text_elements: Vec, + pub(super) mention_bindings: Vec, + pub(super) pending_pastes: Vec<(String, String)>, +} + +impl ThreadComposerState { + pub(super) fn has_content(&self) -> bool { + !self.text.is_empty() + || !self.local_images.is_empty() + || !self.remote_image_urls.is_empty() + || !self.text_elements.is_empty() + || !self.mention_bindings.is_empty() + || !self.pending_pastes.is_empty() + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ThreadInputState { + pub(super) composer: Option, + pub(super) pending_steers: VecDeque, + pub(super) pending_steer_history_records: VecDeque, + pub(super) pending_steer_compare_keys: VecDeque, + pub(super) rejected_steers_queue: VecDeque, + pub(super) rejected_steer_history_records: VecDeque, + pub(super) queued_user_messages: VecDeque, + pub(super) queued_user_message_history_records: VecDeque, + pub(super) user_turn_pending_start: bool, + pub(super) current_collaboration_mode: CollaborationMode, + pub(super) active_collaboration_mask: Option, + pub(super) task_running: bool, + pub(super) agent_turn_running: bool, +} + +impl From for UserMessage { + fn from(text: String) -> Self { + Self { + text, + local_images: Vec::new(), + remote_image_urls: Vec::new(), + // Plain text conversion has no UI element ranges. + text_elements: Vec::new(), + mention_bindings: Vec::new(), + } + } +} + +impl From<&str> for UserMessage { + fn from(text: &str) -> Self { + Self { + text: text.to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + // Plain text conversion has no UI element ranges. + text_elements: Vec::new(), + mention_bindings: Vec::new(), + } + } +} + +#[derive(Debug)] +pub(super) struct PendingSteer { + pub(super) user_message: UserMessage, + pub(super) history_record: UserMessageHistoryRecord, + pub(super) compare_key: PendingSteerCompareKey, +} + +pub(crate) fn create_initial_user_message( + text: Option, + local_image_paths: Vec, + text_elements: Vec, +) -> Option { + let text = text.unwrap_or_default(); + if text.is_empty() && local_image_paths.is_empty() { + None + } else { + let local_images = local_image_paths + .into_iter() + .enumerate() + .map(|(idx, path)| LocalImageAttachment { + placeholder: local_image_label_text(idx + 1), + path, + }) + .collect(); + Some(UserMessage { + text, + local_images, + remote_image_urls: Vec::new(), + text_elements, + mention_bindings: Vec::new(), + }) + } +} + +fn append_text_with_rebased_elements( + target_text: &mut String, + target_text_elements: &mut Vec, + text: &str, + text_elements: impl IntoIterator, +) { + let offset = target_text.len(); + target_text.push_str(text); + target_text_elements.extend(text_elements.into_iter().map(|mut element| { + element.byte_range.start += offset; + element.byte_range.end += offset; + element + })); +} + +pub(super) fn app_server_text_elements(elements: &[TextElement]) -> Vec { + elements.iter().cloned().map(Into::into).collect() +} + +fn build_placeholder_mapping( + local_images: Vec, + next_label: &mut usize, +) -> (HashMap, Vec) { + let mut mapping: HashMap = HashMap::new(); + let mut remapped_images = Vec::new(); + for attachment in local_images { + let new_placeholder = local_image_label_text(*next_label); + *next_label += 1; + mapping.insert(attachment.placeholder.clone(), new_placeholder.clone()); + remapped_images.push(LocalImageAttachment { + placeholder: new_placeholder, + path: attachment.path, + }); + } + (mapping, remapped_images) +} + +fn remap_placeholders_in_text( + text: String, + text_elements: Vec, + mapping: &HashMap, +) -> (String, Vec) { + if mapping.is_empty() { + return (text, text_elements); + } + + let mut elements = text_elements; + elements.sort_by_key(|elem| elem.byte_range.start); + + let mut cursor = 0usize; + let mut rebuilt = String::new(); + let mut rebuilt_elements = Vec::new(); + for mut elem in elements { + let start = elem.byte_range.start.min(text.len()); + let end = elem.byte_range.end.min(text.len()); + if let Some(segment) = text.get(cursor..start) { + rebuilt.push_str(segment); + } + + let original = text.get(start..end).unwrap_or(""); + let placeholder = elem.placeholder(&text); + let replacement = placeholder + .and_then(|ph| mapping.get(ph)) + .map(String::as_str) + .unwrap_or(original); + + let elem_start = rebuilt.len(); + rebuilt.push_str(replacement); + let elem_end = rebuilt.len(); + + if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { + elem.set_placeholder(Some(remapped.clone())); + } + elem.byte_range = (elem_start..elem_end).into(); + rebuilt_elements.push(elem); + cursor = end; + } + if let Some(segment) = text.get(cursor..) { + rebuilt.push_str(segment); + } + + (rebuilt, rebuilt_elements) +} + +// When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering +// its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so +// the combined local_image_paths order matches the labels, even if placeholders were moved +// in the text (e.g., [Image #2] appearing before [Image #1]). Apply the same remapping to +// history overrides so restored drafts and rendered transcript entries agree. +fn remap_placeholders_for_message_and_history_record( + message: UserMessage, + history_record: UserMessageHistoryRecord, + next_label: &mut usize, +) -> (UserMessage, UserMessageHistoryRecord) { + let UserMessage { + text, + text_elements, + local_images, + remote_image_urls, + mention_bindings, + } = message; + let (mapping, remapped_images) = build_placeholder_mapping(local_images, next_label); + let (text, text_elements) = remap_placeholders_in_text(text, text_elements, &mapping); + let history_record = match history_record { + UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { + let (text, text_elements) = + remap_placeholders_in_text(history.text, history.text_elements, &mapping); + UserMessageHistoryRecord::Override(UserMessageHistoryOverride { + text, + text_elements, + }) + } + record => record, + }; + + ( + UserMessage { + text, + local_images: remapped_images, + remote_image_urls, + text_elements, + mention_bindings, + }, + history_record, + ) +} + +#[cfg(test)] +pub(super) fn remap_placeholders_for_message( + message: UserMessage, + next_label: &mut usize, +) -> UserMessage { + remap_placeholders_for_message_and_history_record( + message, + UserMessageHistoryRecord::UserMessageText, + next_label, + ) + .0 +} + +fn remap_user_messages_with_history_records( + messages: Vec<(UserMessage, UserMessageHistoryRecord)>, +) -> Vec<(UserMessage, UserMessageHistoryRecord)> { + let total_remote_images = messages + .iter() + .map(|(message, _)| message.remote_image_urls.len()) + .sum::(); + let mut next_image_label = total_remote_images + 1; + messages + .into_iter() + .map(|(message, history_record)| { + remap_placeholders_for_message_and_history_record( + message, + history_record, + &mut next_image_label, + ) + }) + .collect() +} + +pub(super) fn merge_user_messages(messages: Vec) -> UserMessage { + let messages = remap_user_messages_with_history_records( + messages + .into_iter() + .map(|message| (message, UserMessageHistoryRecord::UserMessageText)) + .collect(), + ); + merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message)) +} + +fn merge_remapped_user_messages(messages: impl IntoIterator) -> UserMessage { + let mut combined = UserMessage { + text: String::new(), + text_elements: Vec::new(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + mention_bindings: Vec::new(), + }; + + for (idx, message) in messages.into_iter().enumerate() { + if idx > 0 { + combined.text.push('\n'); + } + let UserMessage { + text, + text_elements, + local_images, + remote_image_urls, + mention_bindings, + } = message; + append_text_with_rebased_elements( + &mut combined.text, + &mut combined.text_elements, + &text, + text_elements, + ); + combined.local_images.extend(local_images); + combined.remote_image_urls.extend(remote_image_urls); + combined.mention_bindings.extend(mention_bindings); + } + + combined +} + +pub(super) fn user_message_for_restore( + message: UserMessage, + history_record: &UserMessageHistoryRecord, +) -> UserMessage { + match history_record { + UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => UserMessage { + text: history.text.clone(), + text_elements: history.text_elements.clone(), + ..message + }, + UserMessageHistoryRecord::Override(_) | UserMessageHistoryRecord::UserMessageText => { + message + } + } +} + +pub(super) fn user_message_preview_text( + message: &UserMessage, + history_record: Option<&UserMessageHistoryRecord>, +) -> String { + match history_record { + Some(UserMessageHistoryRecord::Override(history)) if !history.text.is_empty() => { + history.text.clone() + } + Some(UserMessageHistoryRecord::Override(_)) + | Some(UserMessageHistoryRecord::UserMessageText) + | None => message.text.clone(), + } +} + +pub(super) fn user_message_display_for_history( + message: UserMessage, + history_record: &UserMessageHistoryRecord, +) -> UserMessageDisplay { + let message = user_message_for_restore(message, history_record); + ChatWidget::user_message_display_from_parts( + message.text, + message.text_elements, + message + .local_images + .into_iter() + .map(|image| image.path) + .collect(), + message.remote_image_urls, + ) +} + +pub(super) fn merge_user_messages_with_history_record( + messages: Vec<(UserMessage, UserMessageHistoryRecord)>, +) -> (UserMessage, UserMessageHistoryRecord) { + let messages = remap_user_messages_with_history_records(messages); + let history_record = if messages + .iter() + .all(|(_, record)| *record == UserMessageHistoryRecord::UserMessageText) + { + UserMessageHistoryRecord::UserMessageText + } else { + let mut history_text = String::new(); + let mut history_text_elements = Vec::new(); + let mut history_segment_count = 0usize; + let mut append_history_segment = |text: &str, text_elements: Vec| { + if history_segment_count > 0 { + history_text.push('\n'); + } + append_text_with_rebased_elements( + &mut history_text, + &mut history_text_elements, + text, + text_elements, + ); + history_segment_count += 1; + }; + for (message, record) in &messages { + match record { + UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { + append_history_segment(&history.text, history.text_elements.clone()); + } + UserMessageHistoryRecord::Override(_) if message.text.is_empty() => {} + UserMessageHistoryRecord::Override(_) + | UserMessageHistoryRecord::UserMessageText => { + append_history_segment(&message.text, message.text_elements.clone()); + } + } + } + UserMessageHistoryRecord::Override(UserMessageHistoryOverride { + text: history_text, + text_elements: history_text_elements, + }) + }; + + ( + merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message)), + history_record, + ) +} #[derive(Clone, Debug, PartialEq)] pub(super) struct UserMessageDisplay { diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 3d47442c90..4ec69bf26f 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -12,6 +12,10 @@ pub struct Cli { #[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)] pub prompt: Option, + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + pub strict_config: bool, + // Internal controls set by the top-level `codex resume` subcommand. // These are not exposed as user flags on the base `codex` command. #[clap(skip)] @@ -63,9 +67,7 @@ pub struct Cli { /// Disable alternate screen mode /// - /// Runs the TUI in inline mode, preserving terminal scrollback history. This is useful - /// in terminal multiplexers like Zellij that follow the xterm spec strictly and disable - /// scrollback in alternate screen buffers. + /// Runs the TUI in inline mode, preserving terminal scrollback history. #[arg(long = "no-alt-screen", default_value_t = false)] pub no_alt_screen: bool, diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 3d0519080e..995a0358f6 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -486,9 +486,8 @@ where } /// Force the next draw pass to repaint the entire viewport by resetting the - /// diff buffer. Call this after operations that move screen content outside of - /// ratatui's knowledge (e.g., Zellij-mode scrolling via raw newlines), since - /// the diff buffer's assumptions about what is currently displayed are invalid. + /// diff buffer. Call this after raw terminal operations that move screen + /// content outside ratatui's knowledge. pub fn invalidate_viewport(&mut self) { self.previous_buffer_mut().reset(); } diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 3f5b53266c..3dd2d8235f 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -145,6 +145,17 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { )); } + if let Some(allow_managed_hooks_only) = requirements_toml.allow_managed_hooks_only { + requirement_lines.push(requirement_line( + "allow_managed_hooks_only", + allow_managed_hooks_only.to_string(), + requirements + .allow_managed_hooks_only + .as_ref() + .map(|sourced| &sourced.source), + )); + } + if requirements_toml.guardian_policy_config.is_some() { requirement_lines.push(requirement_line( "guardian_policy_config", @@ -647,6 +658,10 @@ mod tests { Constrained::allow_any(WebSearchMode::Cached), Some(RequirementSource::CloudRequirements), ), + allow_managed_hooks_only: Some(Sourced::new( + /*value*/ true, + RequirementSource::CloudRequirements, + )), feature_requirements: Some(Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), @@ -684,6 +699,7 @@ mod tests { allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), remote_sandbox_config: None, allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), + allow_managed_hooks_only: Some(true), guardian_policy_config: Some("Use the managed guardian policy.".to_string()), feature_requirements: Some(FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), @@ -741,6 +757,7 @@ mod tests { "allowed_web_search_modes: cached, disabled (source: cloud requirements)" ) ); + assert!(rendered.contains("allow_managed_hooks_only: true (source: cloud requirements)")); assert!( rendered.contains("guardian_policy_config: configured (source: cloud requirements)") ); @@ -893,6 +910,7 @@ approval_policy = "never" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: Some(Vec::new()), + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 6a83594e1f..f2c64a1098 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -1,8 +1,7 @@ //! Inserts finalized history rows into terminal scrollback. //! //! Codex uses the terminal scrollback itself for finalized chat history, so inserting a history -//! cell is an escape-sequence operation rather than a normal ratatui render. The mode determines -//! how to create room for new history above the inline viewport. +//! cell is an escape-sequence operation rather than a normal ratatui render. use std::fmt; use std::io; @@ -35,28 +34,6 @@ use ratatui::style::Modifier; use ratatui::text::Line; use ratatui::text::Span; -/// Selects the terminal escape strategy for inserting history lines above the viewport. -/// -/// Standard terminals support `DECSTBM` scroll regions and Reverse Index (`ESC M`), -/// which let us slide existing content down without redrawing it. Zellij silently -/// drops or mishandles those sequences, so `Zellij` mode falls back to emitting -/// newlines at the bottom of the screen and writing lines at absolute positions. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InsertHistoryMode { - Standard, - Zellij, -} - -impl InsertHistoryMode { - pub fn new(is_zellij: bool) -> Self { - if is_zellij { - Self::Zellij - } else { - Self::Standard - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HistoryLineWrapPolicy { PreWrap, @@ -72,38 +49,12 @@ pub fn insert_history_lines( where B: Backend + Write, { - insert_history_lines_with_mode(terminal, lines, InsertHistoryMode::Standard) + insert_history_lines_with_wrap_policy(terminal, lines, HistoryLineWrapPolicy::PreWrap) } -/// Insert `lines` above the viewport, using the escape strategy selected by `mode`. -/// -/// In `Standard` mode this manipulates DECSTBM scroll regions to slide existing -/// scrollback down and writes new lines into the freed space. In `Zellij` mode it -/// emits newlines at the screen bottom to create space (since Zellij ignores scroll -/// region escapes) and writes lines at computed absolute positions. Both modes -/// update `terminal.viewport_area` so subsequent draw passes know where the -/// viewport moved to. Resize reflow uses the same viewport-aware path after -/// clearing old scrollback. -pub fn insert_history_lines_with_mode( +pub fn insert_history_lines_with_wrap_policy( terminal: &mut crate::custom_terminal::Terminal, lines: Vec, - mode: InsertHistoryMode, -) -> io::Result<()> -where - B: Backend + Write, -{ - insert_history_lines_with_mode_and_wrap_policy( - terminal, - lines, - mode, - HistoryLineWrapPolicy::PreWrap, - ) -} - -pub fn insert_history_lines_with_mode_and_wrap_policy( - terminal: &mut crate::custom_terminal::Terminal, - lines: Vec, - mode: InsertHistoryMode, wrap_policy: HistoryLineWrapPolicy, ) -> io::Result<()> where @@ -151,90 +102,56 @@ where wrapped.extend(line_wrapped); } let wrapped_lines = wrapped_rows as u16; + let cursor_top = if area.bottom() < screen_size.height { + // If the viewport is not at the bottom of the screen, scroll it down to make room. + // Don't scroll it past the bottom of the screen. + let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom()); - match mode { - InsertHistoryMode::Zellij => { - let space_below = screen_size.height.saturating_sub(area.bottom()); - let shift_down = wrapped_lines.min(space_below); - let scroll_up_amount = wrapped_lines.saturating_sub(shift_down); - - if scroll_up_amount > 0 { - // Scroll the entire screen up by emitting \n at the bottom - queue!( - writer, - MoveTo(/*x*/ 0, screen_size.height.saturating_sub(1)) - )?; - for _ in 0..scroll_up_amount { - queue!(writer, Print("\n"))?; - } - } - - if shift_down > 0 { - area.y += shift_down; - should_update_area = true; - } - - let cursor_top = area.top().saturating_sub(scroll_up_amount + shift_down); - queue!(writer, MoveTo(/*x*/ 0, cursor_top))?; - - for (i, line) in wrapped.iter().enumerate() { - if i > 0 { - queue!(writer, Print("\r\n"))?; - } - write_history_line(writer, line, wrap_width)?; - } + let top_1based = area.top() + 1; + queue!(writer, SetScrollRegion(top_1based..screen_size.height))?; + queue!(writer, MoveTo(/*x*/ 0, area.top()))?; + for _ in 0..scroll_amount { + queue!(writer, Print("\x1bM"))?; } - InsertHistoryMode::Standard => { - let cursor_top = if area.bottom() < screen_size.height { - let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom()); + queue!(writer, ResetScrollRegion)?; - let top_1based = area.top() + 1; - queue!(writer, SetScrollRegion(top_1based..screen_size.height))?; - queue!(writer, MoveTo(/*x*/ 0, area.top()))?; - for _ in 0..scroll_amount { - queue!(writer, Print("\x1bM"))?; - } - queue!(writer, ResetScrollRegion)?; + let cursor_top = area.top().saturating_sub(1); + area.y += scroll_amount; + should_update_area = true; + cursor_top + } else { + area.top().saturating_sub(1) + }; - let cursor_top = area.top().saturating_sub(1); - area.y += scroll_amount; - should_update_area = true; - cursor_top - } else { - area.top().saturating_sub(1) - }; + // Limit the scroll region to the lines from the top of the screen to the + // top of the viewport. With this in place, when we add lines inside this + // area, only the lines in this area will be scrolled. We place the cursor + // at the end of the scroll region, and add lines starting there. + // + // ┌─Screen───────────────────────┐ + // │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│ + // │┆ ┆│ + // │┆ ┆│ + // │┆ ┆│ + // │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│ + // │╭─Viewport───────────────────╮│ + // ││ ││ + // │╰────────────────────────────╯│ + // └──────────────────────────────┘ + queue!(writer, SetScrollRegion(1..area.top()))?; - // Limit the scroll region to the lines from the top of the screen to the - // top of the viewport. With this in place, when we add lines inside this - // area, only the lines in this area will be scrolled. We place the cursor - // at the end of the scroll region, and add lines starting there. - // - // ┌─Screen───────────────────────┐ - // │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│ - // │┆ ┆│ - // │┆ ┆│ - // │┆ ┆│ - // │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│ - // │╭─Viewport───────────────────╮│ - // ││ ││ - // │╰────────────────────────────╯│ - // └──────────────────────────────┘ - queue!(writer, SetScrollRegion(1..area.top()))?; + // NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the + // terminal's last_known_cursor_position, which hopefully will still be accurate after we + // fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :) + queue!(writer, MoveTo(/*x*/ 0, cursor_top))?; - // NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the - // terminal's last_known_cursor_position, which hopefully will still be accurate after we - // fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :) - queue!(writer, MoveTo(/*x*/ 0, cursor_top))?; - - for line in &wrapped { - queue!(writer, Print("\r\n"))?; - write_history_line(writer, line, wrap_width)?; - } - - queue!(writer, ResetScrollRegion)?; - } + for line in &wrapped { + queue!(writer, Print("\r\n"))?; + write_history_line(writer, line, wrap_width)?; } + queue!(writer, ResetScrollRegion)?; + // Restore the cursor position to where it was before we started. queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?; @@ -864,10 +781,9 @@ mod tests { let line = Line::from("alpha beta gamma delta epsilon zeta"); - insert_history_lines_with_mode_and_wrap_policy( + insert_history_lines_with_wrap_policy( &mut term, vec![line], - InsertHistoryMode::Standard, HistoryLineWrapPolicy::Terminal, ) .expect("insert raw history"); @@ -954,32 +870,4 @@ mod tests { "expected URL content to appear immediately after prompt (allowing at most one spacer row), got prompt_row={prompt_row}, url_row={url_row}, rows={rows:?}", ); } - - #[test] - fn vt100_zellij_mode_inserts_history_and_updates_viewport() { - let width: u16 = 32; - let height: u16 = 8; - let backend = VT100Backend::new(width, height); - let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - let viewport = Rect::new(/*x*/ 0, /*y*/ 4, width, /*height*/ 2); - term.set_viewport_area(viewport); - - let line: Line<'static> = Line::from("zellij history"); - insert_history_lines_with_mode(&mut term, vec![line], InsertHistoryMode::Zellij) - .expect("insert zellij history"); - - let start_row = 0; - let rows: Vec = term - .backend() - .vt100() - .screen() - .rows(start_row, width) - .collect(); - assert!( - rows.iter().any(|row| row.contains("zellij history")), - "expected zellij history row in screen output, rows: {rows:?}" - ); - assert_eq!(term.viewport_area, Rect::new(0, 5, width, 2)); - assert_eq!(term.visible_history_rows(), 1); - } } diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 1008a00aa5..da5a332360 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -5,6 +5,12 @@ //! cross-terminal inconsistencies in how shifted letters and raw C0 control //! characters are reported. //! +//! List and picker code should match navigation through these helpers instead +//! of comparing `KeyEvent` values directly. The matcher owns compatibility for +//! terminals that report control chords as C0 characters, while +//! `is_plain_text_key_event` gives searchable pickers a shared boundary between +//! text input and navigation commands. +//! //! It also supplies rendering helpers that convert bindings into styled //! `ratatui::text::Span` values for UI hint display. @@ -122,6 +128,27 @@ impl KeyBindingListExt for [KeyBinding] { } } +/// Returns whether an event should be treated as literal text input. +/// +/// Searchable pickers use this to avoid stealing plain printable characters for +/// navigation when the same character might be a valid query. For example, a +/// list may bind `j` and `k` for movement, but a searchable list must let +/// plain `j` update the query while still allowing `Ctrl+J` to move. Calling +/// this after normalizing keybindings would blur that distinction and cause +/// printable search input to disappear. +pub(crate) fn is_plain_text_key_event(event: KeyEvent) -> bool { + matches!( + event, + KeyEvent { + code: KeyCode::Char(ch), + modifiers, + .. + } if !ch.is_ascii_control() + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) + ) +} + pub(crate) const fn plain(key: KeyCode) -> KeyBinding { KeyBinding::new(key, KeyModifiers::NONE) } diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index c12d24ca22..fbac160967 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -201,10 +201,24 @@ pub(crate) struct PagerKeymap { } /// Generic list picker keybindings shared across popup list views. +/// +/// These actions describe list intent rather than a specific widget layout. +/// Vertical actions move the highlighted row, page and jump actions move within +/// the current filtered row set, and horizontal actions are available to views +/// that expose adjacent choices such as tabs, toolbar values, or ordered item +/// movement. Views that also accept search text are responsible for checking +/// `is_plain_text_key_event` before dispatching plain-character bindings so a +/// configured `j`, `k`, `h`, or `l` does not steal query input. #[derive(Clone, Debug)] pub(crate) struct ListKeymap { pub(crate) move_up: Vec, pub(crate) move_down: Vec, + pub(crate) move_left: Vec, + pub(crate) move_right: Vec, + pub(crate) page_up: Vec, + pub(crate) page_down: Vec, + pub(crate) jump_top: Vec, + pub(crate) jump_bottom: Vec, pub(crate) accept: Vec, pub(crate) cancel: Vec, } @@ -505,13 +519,6 @@ impl RuntimeKeymap { close_transcript: resolve_local!(keymap, defaults, pager, close_transcript), }; - let list = ListKeymap { - move_up: resolve_local!(keymap, defaults, list, move_up), - move_down: resolve_local!(keymap, defaults, list, move_down), - accept: resolve_local!(keymap, defaults, list, accept), - cancel: resolve_local!(keymap, defaults, list, cancel), - }; - let approval = ApprovalKeymap { open_fullscreen: resolve_local!(keymap, defaults, approval, open_fullscreen), open_thread: resolve_local!(keymap, defaults, approval, open_thread), @@ -523,6 +530,121 @@ impl RuntimeKeymap { cancel: resolve_local!(keymap, defaults, approval, cancel), }; + let list_move_up = resolve_local!(keymap, defaults, list, move_up); + let list_move_down = resolve_local!(keymap, defaults, list, move_down); + let list_accept = resolve_local!(keymap, defaults, list, accept); + let list_cancel = resolve_local!(keymap, defaults, list, cancel); + let mut configured_bindings_to_preserve = Vec::new(); + for (configured, resolved) in [ + ( + keymap.global.open_transcript.as_ref(), + app.open_transcript.as_slice(), + ), + ( + keymap.global.open_external_editor.as_ref(), + app.open_external_editor.as_slice(), + ), + (keymap.global.copy.as_ref(), app.copy.as_slice()), + ( + keymap.global.clear_terminal.as_ref(), + app.clear_terminal.as_slice(), + ), + ( + keymap.global.toggle_vim_mode.as_ref(), + app.toggle_vim_mode.as_slice(), + ), + ( + keymap.global.toggle_fast_mode.as_ref(), + app.toggle_fast_mode.as_slice(), + ), + ( + keymap.global.toggle_raw_output.as_ref(), + app.toggle_raw_output.as_slice(), + ), + (keymap.list.move_up.as_ref(), list_move_up.as_slice()), + (keymap.list.move_down.as_ref(), list_move_down.as_slice()), + (keymap.list.accept.as_ref(), list_accept.as_slice()), + (keymap.list.cancel.as_ref(), list_cancel.as_slice()), + ( + keymap.approval.open_fullscreen.as_ref(), + approval.open_fullscreen.as_slice(), + ), + ( + keymap.approval.open_thread.as_ref(), + approval.open_thread.as_slice(), + ), + ( + keymap.approval.approve.as_ref(), + approval.approve.as_slice(), + ), + ( + keymap.approval.approve_for_session.as_ref(), + approval.approve_for_session.as_slice(), + ), + ( + keymap.approval.approve_for_prefix.as_ref(), + approval.approve_for_prefix.as_slice(), + ), + (keymap.approval.deny.as_ref(), approval.deny.as_slice()), + ( + keymap.approval.decline.as_ref(), + approval.decline.as_slice(), + ), + (keymap.approval.cancel.as_ref(), approval.cancel.as_slice()), + ] { + if configured.is_none() { + continue; + } + for binding in resolved { + if !configured_bindings_to_preserve.contains(binding) { + configured_bindings_to_preserve.push(*binding); + } + } + } + + let list = ListKeymap { + move_up: list_move_up, + move_down: list_move_down, + move_left: resolve_new_list_bindings( + keymap.list.move_left.as_ref(), + &defaults.list.move_left, + &configured_bindings_to_preserve, + "tui.keymap.list.move_left", + )?, + move_right: resolve_new_list_bindings( + keymap.list.move_right.as_ref(), + &defaults.list.move_right, + &configured_bindings_to_preserve, + "tui.keymap.list.move_right", + )?, + page_up: resolve_new_list_bindings( + keymap.list.page_up.as_ref(), + &defaults.list.page_up, + &configured_bindings_to_preserve, + "tui.keymap.list.page_up", + )?, + page_down: resolve_new_list_bindings( + keymap.list.page_down.as_ref(), + &defaults.list.page_down, + &configured_bindings_to_preserve, + "tui.keymap.list.page_down", + )?, + jump_top: resolve_new_list_bindings( + keymap.list.jump_top.as_ref(), + &defaults.list.jump_top, + &configured_bindings_to_preserve, + "tui.keymap.list.jump_top", + )?, + jump_bottom: resolve_new_list_bindings( + keymap.list.jump_bottom.as_ref(), + &defaults.list.jump_bottom, + &configured_bindings_to_preserve, + "tui.keymap.list.jump_bottom", + )?, + accept: list_accept, + cancel: list_cancel, + }; + let resolved = Self { app, chat, @@ -711,13 +833,21 @@ impl RuntimeKeymap { move_up: default_bindings![ plain(KeyCode::Up), ctrl(KeyCode::Char('p')), + ctrl(KeyCode::Char('k')), plain(KeyCode::Char('k')) ], move_down: default_bindings![ plain(KeyCode::Down), ctrl(KeyCode::Char('n')), + ctrl(KeyCode::Char('j')), plain(KeyCode::Char('j')) ], + move_left: default_bindings![plain(KeyCode::Left), ctrl(KeyCode::Char('h'))], + move_right: default_bindings![plain(KeyCode::Right), ctrl(KeyCode::Char('l'))], + page_up: default_bindings![plain(KeyCode::PageUp), ctrl(KeyCode::Char('b'))], + page_down: default_bindings![plain(KeyCode::PageDown), ctrl(KeyCode::Char('f'))], + jump_top: default_bindings![plain(KeyCode::Home)], + jump_bottom: default_bindings![plain(KeyCode::End)], accept: default_bindings![plain(KeyCode::Enter)], cancel: default_bindings![plain(KeyCode::Esc)], }, @@ -835,7 +965,7 @@ impl RuntimeKeymap { MAIN_RESERVED_BINDINGS, )?; - validate_no_shadow( + validate_no_shadow_with_allowed_overlaps( "app", [ ("open_transcript", self.app.open_transcript.as_slice()), @@ -852,6 +982,12 @@ impl RuntimeKeymap { [ ("list.move_up", self.list.move_up.as_slice()), ("list.move_down", self.list.move_down.as_slice()), + ("list.move_left", self.list.move_left.as_slice()), + ("list.move_right", self.list.move_right.as_slice()), + ("list.page_up", self.list.page_up.as_slice()), + ("list.page_down", self.list.page_down.as_slice()), + ("list.jump_top", self.list.jump_top.as_slice()), + ("list.jump_bottom", self.list.jump_bottom.as_slice()), ("list.accept", self.list.accept.as_slice()), ("list.cancel", self.list.cancel.as_slice()), ( @@ -872,6 +1008,11 @@ impl RuntimeKeymap { ("approval.decline", self.approval.decline.as_slice()), ("approval.cancel", self.approval.cancel.as_slice()), ], + [( + "clear_terminal", + "list.move_right", + key_hint::ctrl(KeyCode::Char('l')), + )], )?; // While the composer is focused, these main-surface handlers always @@ -1123,6 +1264,12 @@ impl RuntimeKeymap { [ ("move_up", self.list.move_up.as_slice()), ("move_down", self.list.move_down.as_slice()), + ("move_left", self.list.move_left.as_slice()), + ("move_right", self.list.move_right.as_slice()), + ("page_up", self.list.page_up.as_slice()), + ("page_down", self.list.page_down.as_slice()), + ("jump_top", self.list.jump_top.as_slice()), + ("jump_bottom", self.list.jump_bottom.as_slice()), ("accept", self.list.accept.as_slice()), ("cancel", self.list.cancel.as_slice()), ], @@ -1152,6 +1299,12 @@ impl RuntimeKeymap { for (action, bindings) in [ ("list.move_up", self.list.move_up.as_slice()), ("list.move_down", self.list.move_down.as_slice()), + ("list.move_left", self.list.move_left.as_slice()), + ("list.move_right", self.list.move_right.as_slice()), + ("list.page_up", self.list.page_up.as_slice()), + ("list.page_down", self.list.page_down.as_slice()), + ("list.jump_top", self.list.jump_top.as_slice()), + ("list.jump_bottom", self.list.jump_bottom.as_slice()), ("list.accept", self.list.accept.as_slice()), ("list.cancel", self.list.cancel.as_slice()), ( @@ -1221,14 +1374,6 @@ See the Codex keymap documentation for supported actions and examples." Ok(()) } -fn validate_no_shadow( - context: &str, - primary: [(&'static str, &[KeyBinding]); N], - shadowed: [(&'static str, &[KeyBinding]); M], -) -> Result<(), String> { - validate_no_shadow_with_allowed_overlaps(context, primary, shadowed, []) -} - fn validate_no_shadow_with_allowed_overlaps( context: &str, primary: [(&'static str, &[KeyBinding]); N], @@ -1372,6 +1517,22 @@ fn resolve_bindings( parse_bindings(spec, path) } +fn resolve_new_list_bindings( + configured: Option<&KeybindingsSpec>, + fallback: &[KeyBinding], + configured_bindings_to_preserve: &[KeyBinding], + path: &str, +) -> Result, String> { + let Some(spec) = configured else { + return Ok(fallback + .iter() + .copied() + .filter(|binding| !configured_bindings_to_preserve.contains(binding)) + .collect()); + }; + parse_bindings(spec, path) +} + /// Parse one keybinding value (`string` or `list[string]`) into concrete bindings. /// /// Duplicate entries are de-duplicated while preserving first-seen order so the @@ -1659,6 +1820,152 @@ mod tests { assert_eq!(runtime.editor.kill_whole_line, Vec::new()); } + #[test] + fn defaults_include_list_page_and_jump_actions() { + let runtime = RuntimeKeymap::defaults(); + + assert_eq!( + runtime.list.move_up, + vec![ + key_hint::plain(KeyCode::Up), + key_hint::ctrl(KeyCode::Char('p')), + key_hint::ctrl(KeyCode::Char('k')), + key_hint::plain(KeyCode::Char('k')), + ] + ); + assert_eq!( + runtime.list.move_down, + vec![ + key_hint::plain(KeyCode::Down), + key_hint::ctrl(KeyCode::Char('n')), + key_hint::ctrl(KeyCode::Char('j')), + key_hint::plain(KeyCode::Char('j')), + ] + ); + assert_eq!( + runtime.list.move_left, + vec![ + key_hint::plain(KeyCode::Left), + key_hint::ctrl(KeyCode::Char('h')), + ] + ); + assert_eq!( + runtime.list.move_right, + vec![ + key_hint::plain(KeyCode::Right), + key_hint::ctrl(KeyCode::Char('l')), + ] + ); + assert_eq!( + runtime.list.page_up, + vec![ + key_hint::plain(KeyCode::PageUp), + key_hint::ctrl(KeyCode::Char('b')), + ] + ); + assert_eq!( + runtime.list.page_down, + vec![ + key_hint::plain(KeyCode::PageDown), + key_hint::ctrl(KeyCode::Char('f')), + ] + ); + assert_eq!(runtime.list.jump_top, vec![key_hint::plain(KeyCode::Home)]); + assert_eq!( + runtime.list.jump_bottom, + vec![key_hint::plain(KeyCode::End)] + ); + } + + #[test] + fn configured_legacy_list_bindings_prune_new_default_overlaps() { + let mut keymap = TuiKeymap::default(); + keymap.list.move_up = Some(one("page-up")); + keymap.list.move_down = Some(one("page-down")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!(runtime.list.move_up, vec![key_hint::plain(KeyCode::PageUp)]); + assert_eq!( + runtime.list.move_down, + vec![key_hint::plain(KeyCode::PageDown)] + ); + assert_eq!( + runtime.list.page_up, + vec![key_hint::ctrl(KeyCode::Char('b'))] + ); + assert_eq!( + runtime.list.page_down, + vec![key_hint::ctrl(KeyCode::Char('f'))] + ); + } + + #[test] + fn configured_legacy_list_bindings_can_prune_all_new_default_keys() { + let mut keymap = TuiKeymap::default(); + keymap.list.move_up = Some(KeybindingsSpec::Many(vec![ + KeybindingSpec("page-up".to_string()), + KeybindingSpec("ctrl-b".to_string()), + ])); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.list.move_up, + vec![ + key_hint::plain(KeyCode::PageUp), + key_hint::ctrl(KeyCode::Char('b')), + ] + ); + assert_eq!(runtime.list.page_up, Vec::new()); + } + + #[test] + fn explicit_new_list_bindings_still_conflict_with_legacy_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.list.move_up = Some(one("page-up")); + keymap.list.page_up = Some(one("page-up")); + + expect_conflict(&keymap, "move_up", "page_up"); + } + + #[test] + fn configured_app_bindings_prune_new_list_default_overlaps() { + let mut keymap = TuiKeymap::default(); + keymap.global.copy = Some(one("page-down")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!(runtime.app.copy, vec![key_hint::plain(KeyCode::PageDown)]); + assert_eq!( + runtime.list.page_down, + vec![key_hint::ctrl(KeyCode::Char('f'))] + ); + } + + #[test] + fn configured_approval_bindings_prune_new_list_default_overlaps() { + let mut keymap = TuiKeymap::default(); + keymap.approval.approve = Some(one("home")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.approval.approve, + vec![key_hint::plain(KeyCode::Home)] + ); + assert_eq!(runtime.list.jump_top, Vec::new()); + } + + #[test] + fn explicit_new_list_bindings_still_conflict_with_configured_approval_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.approval.approve = Some(one("home")); + keymap.list.jump_top = Some(one("home")); + + expect_conflict(&keymap, "list.jump_top", "approval.approve"); + } + #[test] fn vim_normal_defaults_include_insert_and_arrow_aliases() { let runtime = RuntimeKeymap::defaults(); @@ -1734,6 +2041,21 @@ mod tests { keymap.list.move_down = Some(one("up")); expect_conflict(&keymap, "move_up", "move_down"); + + let mut keymap = TuiKeymap::default(); + keymap.list.move_left = Some(one("left")); + keymap.list.move_right = Some(one("left")); + + expect_conflict(&keymap, "move_left", "move_right"); + } + + #[test] + fn rejects_conflicting_list_page_and_jump_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.list.page_up = Some(one("home")); + keymap.list.jump_top = Some(one("home")); + + expect_conflict(&keymap, "page_up", "jump_top"); } #[test] diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 8e11c55377..9ce827b8a9 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -164,6 +164,12 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("pager", "Pager", "close_transcript", "Close the transcript overlay."), action("list", "List", "move_up", "Move list selection up."), action("list", "List", "move_down", "Move list selection down."), + action("list", "List", "move_left", "Move horizontally left in list pickers."), + action("list", "List", "move_right", "Move horizontally right in list pickers."), + action("list", "List", "page_up", "Move list selection up by one page."), + action("list", "List", "page_down", "Move list selection down by one page."), + action("list", "List", "jump_top", "Jump to the first list item."), + action("list", "List", "jump_bottom", "Jump to the last list item."), action("list", "List", "accept", "Accept the current list selection."), action("list", "List", "cancel", "Cancel and close selection views."), action("approval", "Approval", "open_fullscreen", "Open approval details fullscreen."), @@ -286,6 +292,12 @@ pub(super) fn binding_slot<'a>( ("pager", "close_transcript") => Some(&mut keymap.pager.close_transcript), ("list", "move_up") => Some(&mut keymap.list.move_up), ("list", "move_down") => Some(&mut keymap.list.move_down), + ("list", "move_left") => Some(&mut keymap.list.move_left), + ("list", "move_right") => Some(&mut keymap.list.move_right), + ("list", "page_up") => Some(&mut keymap.list.page_up), + ("list", "page_down") => Some(&mut keymap.list.page_down), + ("list", "jump_top") => Some(&mut keymap.list.jump_top), + ("list", "jump_bottom") => Some(&mut keymap.list.jump_bottom), ("list", "accept") => Some(&mut keymap.list.accept), ("list", "cancel") => Some(&mut keymap.list.cancel), ("approval", "open_fullscreen") => Some(&mut keymap.approval.open_fullscreen), @@ -390,6 +402,12 @@ pub(super) fn bindings_for_action<'a>( ("pager", "close_transcript") => Some(runtime_keymap.pager.close_transcript.as_slice()), ("list", "move_up") => Some(runtime_keymap.list.move_up.as_slice()), ("list", "move_down") => Some(runtime_keymap.list.move_down.as_slice()), + ("list", "move_left") => Some(runtime_keymap.list.move_left.as_slice()), + ("list", "move_right") => Some(runtime_keymap.list.move_right.as_slice()), + ("list", "page_up") => Some(runtime_keymap.list.page_up.as_slice()), + ("list", "page_down") => Some(runtime_keymap.list.page_down.as_slice()), + ("list", "jump_top") => Some(runtime_keymap.list.jump_top.as_slice()), + ("list", "jump_bottom") => Some(runtime_keymap.list.jump_bottom.as_slice()), ("list", "accept") => Some(runtime_keymap.list.accept.as_slice()), ("list", "cancel") => Some(runtime_keymap.list.cancel.as_slice()), ("approval", "open_fullscreen") => Some(runtime_keymap.approval.open_fullscreen.as_slice()), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0246b53802..8880935d06 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -8,7 +8,7 @@ use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::find_codex_home; -use crate::legacy_core::config::load_config_as_toml_with_cli_and_loader_overrides; +use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::format_exec_policy_error_with_source; use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; @@ -25,6 +25,7 @@ use codex_app_server_client::InProcessAppServerClient; use codex_app_server_client::InProcessClientStartArgs; use codex_app_server_client::RemoteAppServerClient; use codex_app_server_client::RemoteAppServerConnectArgs; +pub use codex_app_server_client::RemoteAppServerEndpoint; use codex_app_server_protocol::Account as AppServerAccount; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::AuthMode as AppServerAuthMode; @@ -52,7 +53,6 @@ use codex_protocol::config_types::WindowsSandboxLevel; 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; use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use codex_utils_oss::ensure_oss_provider_ready; @@ -272,12 +272,17 @@ pub use public_widgets::composer_input::ComposerAction; pub use public_widgets::composer_input::ComposerInput; // (tests access modules directly within the crate) +#[cfg(unix)] +const AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT: std::time::Duration = + std::time::Duration::from_millis(50); + #[allow(clippy::too_many_arguments)] async fn start_embedded_app_server( arg0_paths: Arg0DispatchPaths, config: Config, cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, + strict_config: bool, cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, @@ -289,6 +294,7 @@ async fn start_embedded_app_server( config, cli_kv_overrides, loader_overrides, + strict_config, cloud_requirements, feedback, log_db, @@ -302,10 +308,7 @@ async fn start_embedded_app_server( #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum AppServerTarget { Embedded, - Remote { - websocket_url: String, - auth_token: Option, - }, + Remote { endpoint: RemoteAppServerEndpoint }, } fn remote_addr_has_explicit_port(addr: &str, parsed: &Url) -> bool { @@ -347,12 +350,24 @@ fn websocket_url_supports_auth_token(parsed: &Url) -> bool { } } -pub fn normalize_remote_addr(addr: &str) -> color_eyre::Result { +pub fn resolve_remote_addr(addr: &str) -> color_eyre::Result { + if let Some(socket_path) = addr.strip_prefix("unix://") { + let socket_path = if socket_path.is_empty() { + let codex_home = find_codex_home().wrap_err("failed to resolve CODEX_HOME")?; + codex_app_server_client::app_server_control_socket_path(&codex_home) + .map_err(color_eyre::Report::new)? + } else { + AbsolutePathBuf::relative_to_current_dir(socket_path) + .map_err(color_eyre::Report::new)? + }; + return Ok(RemoteAppServerEndpoint::UnixSocket { socket_path }); + } + let parsed = match Url::parse(addr) { Ok(parsed) => parsed, Err(_) => { color_eyre::eyre::bail!( - "invalid remote address `{addr}`; expected `ws://host:port` or `wss://host:port`" + "invalid remote address `{addr}`; expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`" ); } }; @@ -363,32 +378,31 @@ pub fn normalize_remote_addr(addr: &str) -> color_eyre::Result { && parsed.query().is_none() && parsed.fragment().is_none() { - return Ok(parsed.to_string()); + return Ok(RemoteAppServerEndpoint::WebSocket { + websocket_url: parsed.to_string(), + auth_token: None, + }); } color_eyre::eyre::bail!( - "invalid remote address `{addr}`; expected `ws://host:port` or `wss://host:port`" + "invalid remote address `{addr}`; expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`" ); } -fn validate_remote_auth_token_transport(websocket_url: &str) -> color_eyre::Result<()> { - let parsed = Url::parse(websocket_url).map_err(color_eyre::Report::new)?; - if websocket_url_supports_auth_token(&parsed) { - return Ok(()); +pub fn remote_addr_supports_auth_token(endpoint: &RemoteAppServerEndpoint) -> bool { + match endpoint { + RemoteAppServerEndpoint::WebSocket { websocket_url, .. } => { + Url::parse(websocket_url).is_ok_and(|parsed| websocket_url_supports_auth_token(&parsed)) + } + RemoteAppServerEndpoint::UnixSocket { .. } => false, } - - color_eyre::eyre::bail!( - "remote auth tokens require `wss://` or loopback `ws://` URLs; got `{websocket_url}`" - ) } async fn connect_remote_app_server( - websocket_url: String, - auth_token: Option, + endpoint: RemoteAppServerEndpoint, ) -> color_eyre::Result { let app_server = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { - websocket_url, - auth_token, + endpoint, client_name: "codex-tui".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, @@ -400,6 +414,40 @@ async fn connect_remote_app_server( Ok(AppServerClient::Remote(app_server)) } +#[cfg(unix)] +async fn maybe_probe_default_daemon_socket(codex_home: &Path) -> Option { + let socket_path = codex_app_server_client::app_server_control_socket_path(codex_home).ok()?; + if !socket_path.as_path().try_exists().unwrap_or(false) { + return None; + } + + match tokio::time::timeout( + AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT, + tokio::net::UnixStream::connect(socket_path.as_path()), + ) + .await + { + Ok(Ok(_stream)) => Some(socket_path), + Ok(Err(err)) => { + tracing::debug!(%err, socket_path = %socket_path.display(), "skipping default app-server daemon socket"); + None + } + Err(_) => { + tracing::debug!( + socket_path = %socket_path.display(), + timeout_ms = AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT.as_millis(), + "timed out probing default app-server daemon socket" + ); + None + } + } +} + +#[cfg(not(unix))] +async fn maybe_probe_default_daemon_socket(_codex_home: &Path) -> Option { + None +} + #[allow(clippy::too_many_arguments)] async fn start_app_server( target: &AppServerTarget, @@ -407,6 +455,7 @@ async fn start_app_server( config: Config, cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, + strict_config: bool, cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, @@ -419,6 +468,7 @@ async fn start_app_server( config, cli_kv_overrides, loader_overrides, + strict_config, cloud_requirements, feedback, log_db, @@ -427,10 +477,7 @@ async fn start_app_server( ) .await .map(AppServerClient::InProcess), - AppServerTarget::Remote { - websocket_url, - auth_token, - } => connect_remote_app_server(websocket_url.clone(), auth_token.clone()).await, + AppServerTarget::Remote { endpoint } => connect_remote_app_server(endpoint.clone()).await, } } @@ -446,6 +493,7 @@ pub(crate) async fn start_app_server_for_picker( config.clone(), Vec::new(), LoaderOverrides::default(), + /*strict_config*/ false, CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, @@ -476,6 +524,7 @@ async fn start_embedded_app_server_with( config: Config, cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, + strict_config: bool, cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, @@ -502,6 +551,7 @@ where config: Arc::new(config), cli_overrides: cli_kv_overrides, loader_overrides, + strict_config, cloud_requirements, feedback, log_db, @@ -714,24 +764,9 @@ pub async fn run_main( mut cli: Cli, arg0_paths: Arg0DispatchPaths, loader_overrides: LoaderOverrides, - remote: Option, - remote_auth_token: Option, + explicit_remote_endpoint: Option, ) -> std::io::Result { - let remote_url = remote; - if let (Some(websocket_url), Some(_)) = (remote_url.as_deref(), remote_auth_token.as_ref()) { - validate_remote_auth_token_transport(websocket_url).map_err(std::io::Error::other)?; - } - let app_server_target = remote_url - .clone() - .map(|websocket_url| AppServerTarget::Remote { - websocket_url, - auth_token: remote_auth_token.clone(), - }) - .unwrap_or(AppServerTarget::Embedded); - let remote_cwd_override = cli - .cwd - .clone() - .filter(|_| matches!(app_server_target, AppServerTarget::Remote { .. })); + let strict_config = cli.strict_config; let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox { ( Some(SandboxMode::DangerFullAccess), @@ -776,6 +811,22 @@ pub async fn run_main( } }; + let remote_endpoint = match explicit_remote_endpoint { + Some(endpoint) => Some(endpoint), + None => maybe_probe_default_daemon_socket(&codex_home) + .await + .map(|socket_path| RemoteAppServerEndpoint::UnixSocket { socket_path }), + }; + let app_server_target = remote_endpoint + .clone() + .map_or(AppServerTarget::Embedded, |endpoint| { + AppServerTarget::Remote { endpoint } + }); + let remote_cwd_override = cli + .cwd + .clone() + .filter(|_| matches!(app_server_target, AppServerTarget::Remote { .. })); + let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), @@ -793,11 +844,14 @@ pub async fn run_main( config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?; #[allow(clippy::print_stderr)] - let config_toml = match load_config_as_toml_with_cli_and_loader_overrides( + let config_toml = match load_config_as_toml_with_cli_and_load_options( &codex_home, config_cwd.as_ref(), cli_kv_overrides.clone(), - loader_overrides.clone(), + codex_config::ConfigLoadOptions { + loader_overrides: loader_overrides.clone(), + strict_config, + }, ) .await { @@ -884,6 +938,7 @@ pub async fn run_main( codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), show_raw_agent_reasoning: cli.oss.then_some(true), + bypass_hook_trust: cli.bypass_hook_trust.then_some(true), additional_writable_roots: additional_dirs, ..Default::default() }; @@ -892,6 +947,8 @@ pub async fn run_main( cli_kv_overrides.clone(), overrides.clone(), cloud_requirements.clone(), + loader_overrides.clone(), + strict_config, ) .await; @@ -947,6 +1004,8 @@ pub async fn run_main( cli_kv_overrides.clone(), overrides.clone(), cloud_requirements.clone(), + loader_overrides.clone(), + strict_config, ) .await; } @@ -1089,6 +1148,7 @@ pub async fn run_main( cli, arg0_paths, loader_overrides, + strict_config, app_server_target, remote_cwd_override, config, @@ -1098,8 +1158,7 @@ pub async fn run_main( feedback, log_db, state_db, - remote_url, - remote_auth_token, + remote_endpoint, environment_manager, ) .await @@ -1111,6 +1170,7 @@ async fn run_ratatui_app( cli: Cli, arg0_paths: Arg0DispatchPaths, loader_overrides: LoaderOverrides, + strict_config: bool, app_server_target: AppServerTarget, remote_cwd_override: Option, initial_config: Config, @@ -1120,8 +1180,7 @@ async fn run_ratatui_app( feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, - remote_url: Option, - remote_auth_token: Option, + remote_endpoint: Option, environment_manager: Arc, ) -> color_eyre::Result { let remote_mode = matches!(&app_server_target, AppServerTarget::Remote { .. }); @@ -1169,30 +1228,30 @@ async fn run_ratatui_app( // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&initial_config); - let mut app_server = Some( - match start_app_server( - &app_server_target, - arg0_paths.clone(), - initial_config.clone(), - cli_kv_overrides.clone(), - loader_overrides.clone(), - cloud_requirements.clone(), - feedback.clone(), - log_db.clone(), - state_db.clone(), - environment_manager.clone(), - ) - .await - { - Ok(app_server) => AppServerSession::new(app_server) - .with_remote_cwd_override(remote_cwd_override.clone()), - Err(err) => { - terminal_restore_guard.restore_silently(); - session_log::log_session_end(); - return Err(err); - } - }, - ); + let app_server_session = match start_app_server( + &app_server_target, + arg0_paths.clone(), + initial_config.clone(), + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + cloud_requirements.clone(), + feedback.clone(), + log_db.clone(), + state_db.clone(), + environment_manager.clone(), + ) + .await + { + Ok(app_server) => AppServerSession::new(app_server), + Err(err) => { + terminal_restore_guard.restore_silently(); + session_log::log_session_end(); + return Err(err); + } + } + .with_remote_cwd_override(remote_cwd_override.clone()); + let mut app_server = Some(app_server_session); let should_show_trust_screen_flag = !remote_mode && should_show_trust_screen(&initial_config); let mut trust_decision_was_made = false; @@ -1263,6 +1322,8 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), cloud_requirements.clone(), + loader_overrides.clone(), + strict_config, ) .await } else { @@ -1465,6 +1526,8 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), cloud_requirements.clone(), + loader_overrides.clone(), + strict_config, fallback_cwd, ) .await @@ -1474,6 +1537,8 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), cloud_requirements.clone(), + loader_overrides.clone(), + strict_config, ) .await } @@ -1515,6 +1580,7 @@ async fn run_ratatui_app( config.clone(), cli_kv_overrides.clone(), loader_overrides, + strict_config, cloud_requirements.clone(), feedback.clone(), log_db.clone(), @@ -1553,8 +1619,7 @@ async fn run_ratatui_app( should_show_trust_screen, // Proxy to: is it a first run in this directory? should_show_trust_screen_flag, // Preserve the startup-time trust NUX signal before onboarding should_prompt_windows_sandbox_nux_at_startup, - remote_url, - remote_auth_token, + remote_endpoint, state_db, environment_manager, startup_hooks_browser, @@ -1614,36 +1679,17 @@ impl Drop for TerminalRestoreGuard { /// Determine whether to use the terminal's alternate screen buffer. /// -/// The alternate screen buffer provides a cleaner fullscreen experience without polluting -/// the terminal's scrollback history. However, it conflicts with terminal multiplexers like -/// Zellij that strictly follow the xterm spec, which disallows scrollback in alternate screen -/// buffers. Zellij intentionally disables scrollback in alternate screen mode (see -/// https://github.com/zellij-org/zellij/pull/1032) and offers no configuration option to -/// change this behavior. -/// -/// This function implements a pragmatic workaround: /// - If `--no-alt-screen` is explicitly passed, always disable alternate screen /// - Otherwise, respect the `tui.alternate_screen` config setting: -/// - `always`: Use alternate screen everywhere (original behavior) +/// - `always`: Use alternate screen /// - `never`: Inline mode only, preserves scrollback -/// - `auto` (default): Auto-detect the terminal multiplexer and disable alternate screen -/// only in Zellij, enabling it everywhere else +/// - `auto` (default): Use alternate screen fn determine_alt_screen_mode(no_alt_screen: bool, tui_alternate_screen: AltScreenMode) -> bool { if no_alt_screen { - false - } else { - match tui_alternate_screen { - AltScreenMode::Always => true, - AltScreenMode::Never => false, - AltScreenMode::Auto => { - let terminal_info = terminal_info(); - !matches!( - terminal_info.multiplexer, - Some(codex_terminal_detection::Multiplexer::Zellij {}) - ) - } - } + return false; } + + tui_alternate_screen != AltScreenMode::Never } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1676,11 +1722,15 @@ async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, cloud_requirements: CloudRequirementsLoader, + loader_overrides: LoaderOverrides, + strict_config: bool, ) -> Config { load_config_or_exit_with_fallback_cwd( cli_kv_overrides, overrides, cloud_requirements, + loader_overrides, + strict_config, /*fallback_cwd*/ None, ) .await @@ -1690,12 +1740,16 @@ async fn load_config_or_exit_with_fallback_cwd( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, cloud_requirements: CloudRequirementsLoader, + loader_overrides: LoaderOverrides, + strict_config: bool, fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] match ConfigBuilder::default() .cli_overrides(cli_kv_overrides) .harness_overrides(overrides) + .loader_overrides(loader_overrides) + .strict_config(strict_config) .cloud_requirements(cloud_requirements) .fallback_cwd(fallback_cwd) .build() @@ -1767,6 +1821,7 @@ mod tests { config, Vec::new(), LoaderOverrides::default(), + /*strict_config*/ false, CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, @@ -1776,6 +1831,26 @@ mod tests { .await } + #[test] + fn alternate_screen_auto_uses_alt_screen() { + assert!(determine_alt_screen_mode( + /*no_alt_screen*/ false, + AltScreenMode::Auto, + )); + assert!(determine_alt_screen_mode( + /*no_alt_screen*/ false, + AltScreenMode::Always, + )); + assert!(!determine_alt_screen_mode( + /*no_alt_screen*/ false, + AltScreenMode::Never, + )); + assert!(!determine_alt_screen_mode( + /*no_alt_screen*/ true, + AltScreenMode::Auto, + )); + } + #[test] fn session_target_display_label_falls_back_to_thread_id() { let thread_id = ThreadId::new(); @@ -1788,81 +1863,103 @@ mod tests { } #[test] - fn normalize_remote_addr_accepts_websocket_url() { + fn resolve_remote_addr_accepts_websocket_url() { assert_eq!( - normalize_remote_addr("ws://127.0.0.1:4500").expect("ws URL should normalize"), - "ws://127.0.0.1:4500/" + resolve_remote_addr("ws://127.0.0.1:4500").expect("ws URL should normalize"), + RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:4500/".to_string(), + auth_token: None, + } ); } #[test] - fn normalize_remote_addr_accepts_secure_websocket_url() { + fn resolve_remote_addr_accepts_secure_websocket_url() { assert_eq!( - normalize_remote_addr("wss://example.com:443").expect("wss URL should normalize"), - "wss://example.com/" + resolve_remote_addr("wss://example.com:443").expect("wss URL should normalize"), + RemoteAppServerEndpoint::WebSocket { + websocket_url: "wss://example.com/".to_string(), + auth_token: None, + } ); } #[test] - fn normalize_remote_addr_rejects_websocket_url_without_explicit_port() { + fn resolve_remote_addr_accepts_default_socket() -> color_eyre::Result<()> { + let codex_home = find_codex_home().wrap_err("failed to resolve CODEX_HOME")?; + assert_eq!( + resolve_remote_addr("unix://")?, + RemoteAppServerEndpoint::UnixSocket { + socket_path: codex_app_server_client::app_server_control_socket_path(&codex_home)?, + } + ); + Ok(()) + } + + #[test] + fn resolve_remote_addr_accepts_relative_socket_path() -> color_eyre::Result<()> { + assert_eq!( + resolve_remote_addr("unix://codex.sock")?, + RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + } + ); + Ok(()) + } + + #[test] + fn resolve_remote_addr_accepts_absolute_socket_path() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let socket_path = temp_dir.path().join("codex.sock"); + assert_eq!( + resolve_remote_addr(&format!("unix://{}", socket_path.display()))?, + RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::from_absolute_path(&socket_path)?, + } + ); + Ok(()) + } + + #[test] + fn resolve_remote_addr_rejects_invalid_remote_addresses() { for addr in [ "ws://127.0.0.1", "wss://example.com", - "ws://user:pass@127.0.0.1", + "127.0.0.1:4500", + "https://127.0.0.1:4500", ] { - let err = normalize_remote_addr(addr) - .expect_err("websocket URLs without an explicit port should be rejected"); - assert!( - err.to_string() - .contains("expected `ws://host:port` or `wss://host:port`") - ); + let err = resolve_remote_addr(addr).expect_err("invalid remote addresses should fail"); + assert!(err.to_string().contains( + "expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`" + )); } } - #[test] - fn normalize_remote_addr_rejects_invalid_input() { - let err = normalize_remote_addr("https://127.0.0.1:4500") - .expect_err("https URLs should be rejected"); + #[tokio::test] + async fn default_daemon_auto_connect_skips_missing_socket() -> color_eyre::Result<()> { + let codex_home = TempDir::new()?; assert!( - err.to_string() - .contains("expected `ws://host:port` or `wss://host:port`") + maybe_probe_default_daemon_socket(codex_home.path()) + .await + .is_none() ); + Ok(()) } - #[test] - fn normalize_remote_addr_rejects_host_port_shortcut() { - let err = - normalize_remote_addr("127.0.0.1:4500").expect_err("host:port should be rejected"); - assert!( - err.to_string() - .contains("expected `ws://host:port` or `wss://host:port`") - ); - } - - #[test] - fn remote_auth_token_transport_accepts_loopback_ws() { - validate_remote_auth_token_transport("ws://127.0.0.1:4500/") - .expect("loopback ws should be allowed for auth tokens"); - validate_remote_auth_token_transport("ws://localhost:4500/") - .expect("localhost ws should be allowed for auth tokens"); - validate_remote_auth_token_transport("ws://[::1]:4500/") - .expect("ipv6 loopback ws should be allowed for auth tokens"); - } - - #[test] - fn remote_auth_token_transport_accepts_secure_wss() { - validate_remote_auth_token_transport("wss://example.com:443/") - .expect("wss should be allowed for auth tokens"); - } - - #[test] - fn remote_auth_token_transport_rejects_non_loopback_ws() { - let err = validate_remote_auth_token_transport("ws://example.com:4500/") - .expect_err("non-loopback ws should be rejected for auth tokens"); - assert!( - err.to_string() - .contains("remote auth tokens require `wss://` or loopback `ws://` URLs") + #[cfg(unix)] + #[tokio::test] + async fn default_daemon_auto_connect_probes_socket_only() -> color_eyre::Result<()> { + let codex_home = TempDir::new()?; + let socket_path = + codex_app_server_client::app_server_control_socket_path(codex_home.path())?; + std::fs::create_dir_all(socket_path.as_path().parent().expect("socket parent"))?; + let _listener = tokio::net::UnixListener::bind(socket_path.as_path())?; + + assert_eq!( + maybe_probe_default_daemon_socket(codex_home.path()).await, + Some(socket_path) ); + Ok(()) } #[tokio::test] @@ -2109,8 +2206,9 @@ mod tests { Path::new("/definitely/not/local/to/this/test") }; let target = AppServerTarget::Remote { - websocket_url: "ws://127.0.0.1:1234/".to_string(), - auth_token: None, + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }, }; let environment_manager = EnvironmentManager::default_for_tests(); @@ -2289,6 +2387,7 @@ mod tests { config, Vec::new(), LoaderOverrides::default(), + /*strict_config*/ false, CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 8b66e5368c..8574b16f8a 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -57,8 +57,7 @@ fn main() -> anyhow::Result<()> { inner, arg0_paths, LoaderOverrides::default(), - /*remote*/ None, - /*remote_auth_token*/ None, + /*explicit_remote_endpoint*/ None, ) .await?; match exit_info.exit_reason { diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 91362bf5a5..8486de32b4 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1056,6 +1056,7 @@ mod tests { config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides: Default::default(), + strict_config: false, cloud_requirements: cloud_requirements_loader_for_storage( codex_home_path.clone(), /*enable_codex_api_key_env*/ false, diff --git a/codex-rs/tui/src/oss_selection.rs b/codex-rs/tui/src/oss_selection.rs index 5396f061c6..ab9176ffbe 100644 --- a/codex-rs/tui/src/oss_selection.rs +++ b/codex-rs/tui/src/oss_selection.rs @@ -1,6 +1,9 @@ use std::io; use std::sync::LazyLock; +use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; use crate::legacy_core::config::set_default_oss_provider; use codex_model_provider_info::DEFAULT_LMSTUDIO_PORT; use codex_model_provider_info::DEFAULT_OLLAMA_PORT; @@ -10,6 +13,7 @@ use crossterm::event::Event; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; use crossterm::event::{self}; use crossterm::execute; use crossterm::terminal::EnterAlternateScreen; @@ -77,6 +81,18 @@ static OSS_SELECT_OPTIONS: LazyLock> = LazyLock::new(|| { ] }); +// This startup wizard runs before the main TUI runtime keymap is available, so +// it mirrors the built-in horizontal list defaults instead of reading config. +// The shared matcher still covers raw C0 Ctrl-H/Ctrl-L terminal reports. +const MOVE_LEFT_KEYS: [KeyBinding; 2] = [ + key_hint::plain(KeyCode::Left), + key_hint::ctrl(KeyCode::Char('h')), +]; +const MOVE_RIGHT_KEYS: [KeyBinding; 2] = [ + key_hint::plain(KeyCode::Right), + key_hint::ctrl(KeyCode::Char('l')), +]; + pub struct OssSelectionWidget<'a> { select_options: &'a Vec, confirmation_prompt: Paragraph<'a>, @@ -178,29 +194,35 @@ impl OssSelectionWidget<'_> { } fn handle_select_key(&mut self, key_event: KeyEvent) { - match key_event.code { - KeyCode::Char('c') - if key_event - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) => - { + match key_event { + KeyEvent { + code: KeyCode::Char('c'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { self.send_decision("__CANCELLED__".to_string()); } - KeyCode::Left => { + _ if MOVE_LEFT_KEYS.is_pressed(key_event) => { self.selected_option = (self.selected_option + self.select_options.len() - 1) % self.select_options.len(); } - KeyCode::Right => { + _ if MOVE_RIGHT_KEYS.is_pressed(key_event) => { self.selected_option = (self.selected_option + 1) % self.select_options.len(); } - KeyCode::Enter => { + KeyEvent { + code: KeyCode::Enter, + .. + } => { let opt = &self.select_options[self.selected_option]; self.send_decision(opt.provider_id.to_string()); } - KeyCode::Esc => { + KeyEvent { + code: KeyCode::Esc, .. + } => { self.send_decision(LMSTUDIO_OSS_PROVIDER_ID.to_string()); } - other => { + KeyEvent { code, .. } => { + let other = code; let normalized = Self::normalize_keycode(other); if let Some(opt) = self .select_options @@ -371,3 +393,20 @@ async fn check_port_status(port: u16) -> io::Result { Err(_) => Ok(false), // Connection failed = not running } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ctrl_h_l_move_provider_selection() { + let mut widget = OssSelectionWidget::new(ProviderStatus::Unknown, ProviderStatus::Unknown) + .expect("widget should initialize"); + + assert_eq!(widget.selected_option, 0); + widget.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert_eq!(widget.selected_option, 1); + widget.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(widget.selected_option, 0); + } +} diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 5d5e888390..527c84a8e2 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -10,6 +10,9 @@ use crate::app_server_session::AppServerSession; use crate::color::blend; use crate::color::is_light; use crate::git_action_directives::parse_assistant_markdown; +use crate::key_hint::KeyBindingListExt; +use crate::key_hint::is_plain_text_key_event; +use crate::keymap::ListKeymap; use crate::keymap::PagerKeymap; use crate::keymap::RuntimeKeymap; use crate::legacy_core::config::Config; @@ -282,6 +285,7 @@ struct SessionPickerRunOptions { initial_density: SessionListDensity, view_persistence: Option, pager_keymap: PagerKeymap, + list_keymap: ListKeymap, } /// Interactive session picker that lists app-server threads with simple search, @@ -354,7 +358,7 @@ async fn run_resume_picker_with_launch_context( ); let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, is_remote); let provider_filter = picker_provider_filter(config, is_remote); - let pager_keymap = picker_pager_keymap(config)?; + let runtime_keymap = picker_runtime_keymap(config)?; let options = SessionPickerRunOptions { show_all, filter_cwd: cwd_filter, @@ -367,7 +371,8 @@ async fn run_resume_picker_with_launch_context( codex_home: config.codex_home.to_path_buf(), active_profile: config.active_profile.clone(), }), - pager_keymap, + pager_keymap: runtime_keymap.pager, + list_keymap: runtime_keymap.list, }; run_session_picker_with_loader( tui, @@ -399,7 +404,7 @@ pub async fn run_fork_picker_with_app_server( ); let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, is_remote); let provider_filter = picker_provider_filter(config, is_remote); - let pager_keymap = picker_pager_keymap(config)?; + let runtime_keymap = picker_runtime_keymap(config)?; let options = SessionPickerRunOptions { show_all, filter_cwd: cwd_filter, @@ -412,7 +417,8 @@ pub async fn run_fork_picker_with_app_server( codex_home: config.codex_home.to_path_buf(), active_profile: config.active_profile.clone(), }), - pager_keymap, + pager_keymap: runtime_keymap.pager, + list_keymap: runtime_keymap.list, }; run_session_picker_with_loader( tui, @@ -447,6 +453,7 @@ async fn run_session_picker_with_loader( state.density = options.initial_density; state.view_persistence = options.view_persistence; state.pager_keymap = options.pager_keymap; + state.list_keymap = options.list_keymap; state.launch_context = options.launch_context; state.start_initial_load(); state.request_frame(); @@ -516,9 +523,8 @@ fn picker_provider_filter(config: &Config, is_remote: bool) -> ProviderFilter { } } -fn picker_pager_keymap(config: &Config) -> Result { +fn picker_runtime_keymap(config: &Config) -> Result { RuntimeKeymap::from_config(&config.tui_keymap) - .map(|keymap| keymap.pager) .map_err(|err| color_eyre::eyre::eyre!("invalid keymap configuration: {err}")) } @@ -656,6 +662,7 @@ struct PickerState { transcript_loading_frame_shown: bool, overlay: Option, pager_keymap: PagerKeymap, + list_keymap: ListKeymap, } struct PaginationState { @@ -928,6 +935,7 @@ impl PickerState { transcript_loading_frame_shown: false, overlay: None, pager_keymap: RuntimeKeymap::defaults().pager, + list_keymap: RuntimeKeymap::defaults().list, } } @@ -1028,18 +1036,14 @@ impl PickerState { if self.is_transcript_loading() { return Ok(self.handle_transcript_loading_key(key)); } - if !matches!(key.code, KeyCode::PageDown) { + if !self.list_keymap.page_down.is_pressed(key) { self.pending_page_down_target = None; } + // The session picker is always searchable, so plain text belongs to + // the query first. Modified list bindings still route through the + // runtime keymap below. + let allow_plain_char_navigation = !is_plain_text_key_event(key); match key { - KeyEvent { - code: KeyCode::Esc, .. - } => { - if self.query.is_empty() { - return Ok(Some(SessionSelection::StartFresh)); - } - self.clear_query_preserving_selection(); - } KeyEvent { code: KeyCode::Char('c'), modifiers, @@ -1047,6 +1051,12 @@ impl PickerState { } if modifiers.contains(KeyModifiers::CONTROL) => { return Ok(Some(SessionSelection::Exit)); } + _ if self.list_keymap.cancel.is_pressed(key) => { + if self.query.is_empty() { + return Ok(Some(SessionSelection::StartFresh)); + } + self.clear_query_preserving_selection(); + } KeyEvent { code: KeyCode::Char('t'), modifiers, @@ -1089,10 +1099,7 @@ impl PickerState { } /* ^O */ => { self.toggle_density().await; } - KeyEvent { - code: KeyCode::Enter, - .. - } => { + _ if self.list_keymap.accept.is_pressed(key) => { if let Some(row) = self.filtered_rows.get(self.selected) { let path = row.path.clone(); let thread_id = match row.thread_id { @@ -1119,39 +1126,14 @@ impl PickerState { self.request_frame(); } } - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{0010}'), - modifiers: KeyModifiers::NONE, - .. - } /* ^P */ => { + _ if allow_plain_char_navigation && self.list_keymap.move_up.is_pressed(key) => { if self.selected > 0 { self.selected -= 1; self.ensure_selected_visible(); } self.request_frame(); } - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{000e}'), - modifiers: KeyModifiers::NONE, - .. - } /* ^N */ => { + _ if allow_plain_char_navigation && self.list_keymap.move_down.is_pressed(key) => { if self.selected + 1 < self.filtered_rows.len() { self.selected += 1; self.ensure_selected_visible(); @@ -1159,10 +1141,7 @@ impl PickerState { self.maybe_load_more_for_scroll(); self.request_frame(); } - KeyEvent { - code: KeyCode::PageUp, - .. - } => { + _ if allow_plain_char_navigation && self.list_keymap.page_up.is_pressed(key) => { let step = self.view_rows.unwrap_or(10).max(1); if self.selected > 0 { self.selected = self.selected.saturating_sub(step); @@ -1170,19 +1149,14 @@ impl PickerState { self.request_frame(); } } - KeyEvent { - code: KeyCode::Home, - .. - } => { + _ if allow_plain_char_navigation && self.list_keymap.jump_top.is_pressed(key) => { if !self.filtered_rows.is_empty() { self.selected = 0; self.ensure_selected_visible(); self.request_frame(); } } - KeyEvent { - code: KeyCode::End, .. - } => { + _ if allow_plain_char_navigation && self.list_keymap.jump_bottom.is_pressed(key) => { if !self.filtered_rows.is_empty() { self.selected = self.filtered_rows.len().saturating_sub(1); self.ensure_selected_visible(); @@ -1190,10 +1164,7 @@ impl PickerState { self.request_frame(); } } - KeyEvent { - code: KeyCode::PageDown, - .. - } => { + _ if allow_plain_char_navigation && self.list_keymap.page_down.is_pressed(key) => { if !self.filtered_rows.is_empty() { let step = self.view_rows.unwrap_or(10).max(1); let target = self.selected.saturating_add(step); @@ -1222,14 +1193,10 @@ impl PickerState { self.focus_previous_toolbar_control(); self.request_frame(); } - KeyEvent { - code: KeyCode::Left, - .. - } - | KeyEvent { - code: KeyCode::Right, - .. - } => { + _ if allow_plain_char_navigation + && (self.list_keymap.move_left.is_pressed(key) + || self.list_keymap.move_right.is_pressed(key)) => + { self.change_focused_toolbar_value(); self.request_frame(); } @@ -5382,7 +5349,7 @@ session_picker_view = "dense" .await .unwrap(); state - .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)) .await .unwrap(); @@ -5554,6 +5521,89 @@ session_picker_view = "dense" assert_eq!(state.selected, 0); } + #[tokio::test] + async fn page_and_jump_navigation_use_list_keymap() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.list_keymap.page_down = vec![crate::key_hint::ctrl(KeyCode::Char('d'))]; + state.list_keymap.page_up = vec![crate::key_hint::ctrl(KeyCode::Char('u'))]; + state.list_keymap.jump_bottom = vec![crate::key_hint::ctrl(KeyCode::Char('y'))]; + state.list_keymap.jump_top = vec![crate::key_hint::ctrl(KeyCode::Char('a'))]; + + let mut items = Vec::new(); + for idx in 0..20 { + let ts = format!("2025-01-{:02}T00:00:00Z", idx + 1); + let preview = format!("item-{idx}"); + let path = format!("/tmp/item-{idx}.jsonl"); + items.push(make_row(&path, &ts, &preview)); + } + + state.reset_pagination(); + state.ingest_page(page( + items, /*next_cursor*/ None, /*num_scanned_files*/ 20, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + + state + .handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)) + .await + .unwrap(); + assert_eq!(state.selected, 0); + + state + .handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)) + .await + .unwrap(); + assert_eq!(state.selected, 5); + + state + .handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)) + .await + .unwrap(); + assert_eq!(state.selected, 0); + + state + .handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)) + .await + .unwrap(); + assert_eq!(state.selected, 19); + + state + .handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)) + .await + .unwrap(); + assert_eq!(state.selected, 0); + } + + #[tokio::test] + async fn ctrl_c_exits_even_when_cancel_is_remapped_to_ctrl_c() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.list_keymap.cancel = vec![crate::key_hint::ctrl(KeyCode::Char('c'))]; + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert!(matches!(selection, Some(SessionSelection::Exit))); + } + #[tokio::test] async fn end_jumps_to_last_known_row_and_starts_loading_more() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__bypass_hook_trust_startup_warning.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__bypass_hook_trust_startup_warning.snap new file mode 100644 index 0000000000..bd3a7871db --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__bypass_hook_trust_startup_warning.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests.rs +expression: rendered +--- +⚠ `--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without + review for this invocation. 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 f58386a8d5..898108c755 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,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 87 actions, 1 customized, 2 unbound. + 93 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug 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 index 1c247951fd..4d9440cb4e 100644 --- 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 @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 88 actions, 0 customized, 3 unbound. + 94 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug 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 e094edad97..58b1b80803 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,7 +2,7 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (87 selectable) +tab: All (93 selectable) tab: Common (19 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) @@ -10,7 +10,7 @@ tab: App (9 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (34 selectable) -tab: Navigation (14 selectable) +tab: Navigation (20 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 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 f146c7eed1..6a98da9238 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,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 87 actions, 0 customized, 2 unbound. + 93 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug 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 c9b8be80fd..9d651ee4f0 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,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 87 actions, 0 customized, 2 unbound. + 93 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 9255d188c4..2d22e161fa 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -33,7 +33,6 @@ use ratatui::crossterm::terminal::enable_raw_mode; use ratatui::layout::Offset; use ratatui::layout::Position; use ratatui::layout::Rect; -use ratatui::layout::Size; use ratatui::text::Line; use tokio::sync::broadcast; use tokio_stream::Stream; @@ -440,8 +439,7 @@ pub struct Tui { enhanced_keys_supported: bool, notification_backend: Option, notification_condition: NotificationCondition, - is_zellij: bool, - // When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support) + // When false, enter_alt_screen() becomes a no-op. alt_screen_enabled: bool, } @@ -474,10 +472,6 @@ impl Tui { // Cache this to avoid contention with the event reader. supports_color::on_cached(supports_color::Stream::Stdout); let _ = crate::terminal_palette::default_colors(); - let is_zellij = matches!( - codex_terminal_detection::terminal_info().multiplexer, - Some(codex_terminal_detection::Multiplexer::Zellij {}) - ); Self { frame_requester, @@ -495,7 +489,6 @@ impl Tui { enhanced_keys_supported, notification_backend: Some(detect_backend(NotificationMethod::default())), notification_condition: NotificationCondition::default(), - is_zellij, alt_screen_enabled: true, } } @@ -687,62 +680,6 @@ impl Tui { self.pending_history_lines.clear(); } - /// Resize the inline viewport to `height` rows, scrolling content above it if - /// the viewport would extend past the bottom of the screen. Returns `true` when - /// the caller must invalidate the diff buffer (Zellij mode), because the scroll - /// was performed with raw newlines that ratatui cannot track. - fn update_inline_viewport( - terminal: &mut Terminal, - height: u16, - is_zellij: bool, - ) -> Result { - let size = terminal.size()?; - let mut needs_full_repaint = false; - - let mut area = terminal.viewport_area; - area.height = height.min(size.height); - area.width = size.width; - if area.bottom() > size.height { - let scroll_by = area.bottom() - size.height; - if is_zellij { - Self::scroll_zellij_expanded_viewport(terminal, size, scroll_by)?; - needs_full_repaint = true; - } else { - terminal - .backend_mut() - .scroll_region_up(0..area.top(), scroll_by)?; - } - area.y = size.height - area.height; - } - if area != terminal.viewport_area { - // On startup, the old viewport can still be empty. Clear from the - // new viewport top so stale shell cells do not show through spaces. - clear_for_viewport_change(terminal, area)?; - terminal.set_viewport_area(area); - } - - Ok(needs_full_repaint) - } - - /// Push content above the viewport upward by `scroll_by` rows using raw - /// newlines at the screen bottom. This is the Zellij-safe alternative to - /// `scroll_region_up`, which relies on DECSTBM sequences Zellij does not - /// support. - fn scroll_zellij_expanded_viewport( - terminal: &mut Terminal, - size: Size, - scroll_by: u16, - ) -> Result<()> { - crossterm::queue!( - terminal.backend_mut(), - crossterm::cursor::MoveTo(0, size.height.saturating_sub(1)) - )?; - for _ in 0..scroll_by { - crossterm::queue!(terminal.backend_mut(), crossterm::style::Print("\n"))?; - } - Ok(()) - } - /// Resize the inline viewport for the resize-reflow path. /// /// Unlike the legacy draw path, this path does not scroll rows above the viewport when the @@ -751,7 +688,6 @@ impl Tui { fn update_inline_viewport_for_resize_reflow( terminal: &mut Terminal, height: u16, - is_zellij: bool, ) -> Result { let size = terminal.size()?; let terminal_height_shrank = size.height < terminal.last_known_screen_size.height; @@ -768,13 +704,9 @@ impl Tui { if area.bottom() > size.height { let scroll_by = area.bottom() - size.height; if !terminal_height_shrank { - if is_zellij { - Self::scroll_zellij_expanded_viewport(terminal, size, scroll_by)?; - } else { - terminal - .backend_mut() - .scroll_region_up(0..area.top(), scroll_by)?; - } + terminal + .backend_mut() + .scroll_region_up(0..area.top(), scroll_by)?; } area.y = size.height - area.height; } else if terminal_height_grew && viewport_was_bottom_aligned { @@ -792,27 +724,23 @@ impl Tui { } /// Write any buffered history lines above the viewport and clear the buffer. - /// Returns `true` when Zellij mode was used, signaling that the caller must - /// invalidate the diff buffer for a full repaint. fn flush_pending_history_lines( terminal: &mut Terminal, pending_history_lines: &mut Vec, - is_zellij: bool, - ) -> Result { + ) -> Result<()> { if pending_history_lines.is_empty() { - return Ok(false); + return Ok(()); } for batch in pending_history_lines.iter() { - crate::insert_history::insert_history_lines_with_mode_and_wrap_policy( + crate::insert_history::insert_history_lines_with_wrap_policy( terminal, batch.lines.clone(), - crate::insert_history::InsertHistoryMode::new(is_zellij), batch.wrap_policy, )?; } pending_history_lines.clear(); - Ok(is_zellij) + Ok(()) } pub fn draw( @@ -843,17 +771,26 @@ impl Tui { terminal.clear()?; } - let mut needs_full_repaint = - Self::update_inline_viewport(terminal, height, self.is_zellij)?; - needs_full_repaint |= Self::flush_pending_history_lines( - terminal, - &mut self.pending_history_lines, - self.is_zellij, - )?; + let size = terminal.size()?; - if needs_full_repaint { - terminal.invalidate_viewport(); + let mut area = terminal.viewport_area; + area.height = height.min(size.height); + area.width = size.width; + // If the viewport has expanded, scroll everything else up to make room. + if area.bottom() > size.height { + terminal + .backend_mut() + .scroll_region_up(0..area.top(), area.bottom() - size.height)?; + area.y = size.height - area.height; } + if area != terminal.viewport_area { + // On startup, the old viewport can still be empty. Clear from the + // new viewport top so stale shell cells do not show through spaces. + clear_for_viewport_change(terminal, area)?; + terminal.set_viewport_area(area); + } + + Self::flush_pending_history_lines(terminal, &mut self.pending_history_lines)?; // Update the y position for suspending so Ctrl-Z can place the cursor correctly. #[cfg(unix)] @@ -943,14 +880,9 @@ impl Tui { } let terminal = &mut self.terminal; - let mut needs_full_repaint = - Self::update_inline_viewport_for_resize_reflow(terminal, height, self.is_zellij)?; - let flushed_history = Self::flush_pending_history_lines( - terminal, - &mut self.pending_history_lines, - self.is_zellij, - )?; - needs_full_repaint |= flushed_history; + let needs_full_repaint = + Self::update_inline_viewport_for_resize_reflow(terminal, height)?; + Self::flush_pending_history_lines(terminal, &mut self.pending_history_lines)?; if needs_full_repaint { terminal.invalidate_viewport(); diff --git a/codex-rs/tui/tests/suite/model_availability_nux.rs b/codex-rs/tui/tests/suite/model_availability_nux.rs index 5a384e8280..836dfe7ea4 100644 --- a/codex-rs/tui/tests/suite/model_availability_nux.rs +++ b/codex-rs/tui/tests/suite/model_availability_nux.rs @@ -4,11 +4,14 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use codex_models_manager::bundled_models_response; +use core_test_support::responses; +use core_test_support::skip_if_no_network; use serde_json::Value as JsonValue; use tempfile::tempdir; use tokio::select; use tokio::time::sleep; use tokio::time::timeout; +use wiremock::MockServer; #[tokio::test] async fn resume_startup_does_not_consume_model_availability_nux_count() -> Result<()> { @@ -16,6 +19,7 @@ async fn resume_startup_does_not_consume_model_availability_nux_count() -> Resul if cfg!(windows) { return Ok(()); } + skip_if_no_network!(Ok(())); let repo_root = codex_utils_cargo_bin::repo_root()?; let codex_home = tempdir()?; @@ -68,8 +72,14 @@ trust_level = "trusted" ); std::fs::write(codex_home.path().join("config.toml"), config_contents)?; - let fixture_path = - codex_utils_cargo_bin::find_resource!("../core/tests/cli_responses_fixture.sse")?; + let server = MockServer::start().await; + let sse = responses::sse(vec![ + responses::ev_response_created("resp-seed-session"), + responses::ev_assistant_message("msg-seed-session", "seed session response"), + responses::ev_completed("resp-seed-session"), + ]); + let _response_mock = responses::mount_sse_once(&server, sse).await; + let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); let codex = if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") { path } else { @@ -85,12 +95,13 @@ trust_level = "trusted" let exec_output = std::process::Command::new(&codex) .arg("exec") .arg("--skip-git-repo-check") + .arg("-c") + .arg(&openai_base_url_config) .arg("-C") .arg(&repo_root) .arg("seed session for resume") .env("CODEX_HOME", codex_home.path()) .env("OPENAI_API_KEY", "dummy") - .env("CODEX_RS_SSE_FIXTURE", fixture_path) .output() .context("failed to execute codex exec")?; anyhow::ensure!( @@ -114,6 +125,8 @@ trust_level = "trusted" repo_root.display().to_string(), "-c".to_string(), "analytics.enabled=false".to_string(), + "-c".to_string(), + openai_base_url_config, ]; let spawned = codex_utils_pty::spawn_pty_process( diff --git a/codex-rs/tui/tests/suite/resize_reflow.rs b/codex-rs/tui/tests/suite/resize_reflow.rs index 53c1c5da94..af5ed445b2 100644 --- a/codex-rs/tui/tests/suite/resize_reflow.rs +++ b/codex-rs/tui/tests/suite/resize_reflow.rs @@ -8,14 +8,18 @@ use std::time::Instant; use anyhow::Context; use anyhow::Result; +use core_test_support::responses; +use core_test_support::skip_if_no_network; use tempfile::tempdir; +use wiremock::MockServer; -#[test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"] -fn tmux_split_preserves_fresh_session_composer_row_after_resize_reflow() -> Result<()> { +async fn tmux_split_preserves_fresh_session_composer_row_after_resize_reflow() -> Result<()> { if cfg!(windows) { return Ok(()); } + skip_if_no_network!(Ok(())); if Command::new("tmux").arg("-V").output().is_err() { eprintln!("skipping resize smoke because tmux is unavailable"); return Ok(()); @@ -24,9 +28,9 @@ fn tmux_split_preserves_fresh_session_composer_row_after_resize_reflow() -> Resu let repo_root = codex_utils_cargo_bin::repo_root()?; let codex = codex_binary(&repo_root)?; let codex_home = tempdir()?; - let fixture_dir = tempdir()?; - let fixture = fixture_dir.path().join("resize-reflow.sse"); - write_fixture(&fixture)?; + let server = MockServer::start().await; + let _response_mock = responses::mount_sse_once(&server, resize_reflow_sse()).await; + let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); write_config( codex_home.path(), &repo_root, @@ -57,10 +61,11 @@ fn tmux_split_preserves_fresh_session_composer_row_after_resize_reflow() -> Resu .arg("env") .arg(format!("CODEX_HOME={}", codex_home.path().display())) .arg("OPENAI_API_KEY=dummy") - .arg(format!("CODEX_RS_SSE_FIXTURE={}", fixture.display())) .arg(codex) .arg("-c") .arg("analytics.enabled=false") + .arg("-c") + .arg(&openai_base_url_config) .arg("--no-alt-screen") .arg("-C") .arg(&repo_root) @@ -162,29 +167,31 @@ fn tmux_split_preserves_fresh_session_composer_row_after_resize_reflow() -> Resu Ok(()) } -#[test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"] -fn tmux_repeated_resizes_do_not_push_composer_down() -> Result<()> { +async fn tmux_repeated_resizes_do_not_push_composer_down() -> Result<()> { if cfg!(windows) { return Ok(()); } + skip_if_no_network!(Ok(())); if Command::new("tmux").arg("-V").output().is_err() { eprintln!("skipping resize smoke because tmux is unavailable"); return Ok(()); } - run_repeated_resize_smoke(/*terminal_resize_reflow_enabled*/ false)?; - run_repeated_resize_smoke(/*terminal_resize_reflow_enabled*/ true)?; + run_repeated_resize_smoke(/*terminal_resize_reflow_enabled*/ false).await?; + run_repeated_resize_smoke(/*terminal_resize_reflow_enabled*/ true).await?; Ok(()) } -#[test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"] -fn tmux_width_resize_restore_keeps_visible_content_anchored() -> Result<()> { +async fn tmux_width_resize_restore_keeps_visible_content_anchored() -> Result<()> { if cfg!(windows) { return Ok(()); } + skip_if_no_network!(Ok(())); if Command::new("tmux").arg("-V").output().is_err() { eprintln!("skipping resize smoke because tmux is unavailable"); return Ok(()); @@ -193,9 +200,9 @@ fn tmux_width_resize_restore_keeps_visible_content_anchored() -> Result<()> { let repo_root = codex_utils_cargo_bin::repo_root()?; let codex = codex_binary(&repo_root)?; let codex_home = tempdir()?; - let fixture_dir = tempdir()?; - let fixture = fixture_dir.path().join("resize-reflow.sse"); - write_fixture(&fixture)?; + let server = MockServer::start().await; + let _response_mock = responses::mount_sse_once(&server, resize_reflow_sse()).await; + let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); write_config( codex_home.path(), &repo_root, @@ -226,10 +233,11 @@ fn tmux_width_resize_restore_keeps_visible_content_anchored() -> Result<()> { .arg("env") .arg(format!("CODEX_HOME={}", codex_home.path().display())) .arg("OPENAI_API_KEY=dummy") - .arg(format!("CODEX_RS_SSE_FIXTURE={}", fixture.display())) .arg(codex) .arg("-c") .arg("analytics.enabled=false") + .arg("-c") + .arg(&openai_base_url_config) .arg("--no-alt-screen") .arg("-C") .arg(&repo_root) @@ -312,13 +320,13 @@ fn tmux_width_resize_restore_keeps_visible_content_anchored() -> Result<()> { Ok(()) } -fn run_repeated_resize_smoke(terminal_resize_reflow_enabled: bool) -> Result<()> { +async fn run_repeated_resize_smoke(terminal_resize_reflow_enabled: bool) -> Result<()> { let repo_root = codex_utils_cargo_bin::repo_root()?; let codex = codex_binary(&repo_root)?; let codex_home = tempdir()?; - let fixture_dir = tempdir()?; - let fixture = fixture_dir.path().join("resize-reflow.sse"); - write_fixture(&fixture)?; + let server = MockServer::start().await; + let _response_mock = responses::mount_sse_once(&server, resize_reflow_sse()).await; + let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); write_config( codex_home.path(), &repo_root, @@ -354,10 +362,11 @@ fn run_repeated_resize_smoke(terminal_resize_reflow_enabled: bool) -> Result<()> .arg("env") .arg(format!("CODEX_HOME={}", codex_home.path().display())) .arg("OPENAI_API_KEY=dummy") - .arg(format!("CODEX_RS_SSE_FIXTURE={}", fixture.display())) .arg(codex) .arg("-c") .arg("analytics.enabled=false") + .arg("-c") + .arg(&openai_base_url_config) .arg("--no-alt-screen") .arg("-C") .arg(&repo_root) @@ -510,33 +519,13 @@ fn write_auth(codex_home: &Path) -> Result<()> { Ok(()) } -fn write_fixture(path: &Path) -> Result<()> { +fn resize_reflow_sse() -> String { let text = "resize reflow sentinel says hi. This paragraph is intentionally long enough to exercise terminal wrapping, scrollback redraw, and pane resize behavior without requiring a live model response. It includes enough ordinary prose to wrap across several rows in a narrow tmux pane, then keep going so repeated split and restore cycles have visible history above the composer. If a resize path accidentally inserts blank rows or anchors the viewport lower on each pass, the composer row will drift after the pane returns to its original height."; - let created = serde_json::json!({ - "type": "response.created", - "response": { "id": "resp-resize-smoke" }, - }); - let done = serde_json::json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "role": "assistant", - "content": [ - { "type": "output_text", "text": text } - ], - }, - }); - let completed = serde_json::json!({ - "type": "response.completed", - "response": { "id": "resp-resize-smoke", "output": [] }, - }); - let fixture = format!( - "event: response.created\ndata: {created}\n\n\ - event: response.output_item.done\ndata: {done}\n\n\ - event: response.completed\ndata: {completed}\n\n" - ); - std::fs::write(path, fixture)?; - Ok(()) + responses::sse(vec![ + responses::ev_response_created("resp-resize-smoke"), + responses::ev_assistant_message("msg-resize-smoke", text), + responses::ev_completed("resp-resize-smoke"), + ]) } fn wait_for_capture_contains(pane: &str, needle: &str, timeout: Duration) -> Result { diff --git a/codex-rs/utils/cli/src/config_override.rs b/codex-rs/utils/cli/src/config_override.rs index 41be3ca6b6..8368b10f89 100644 --- a/codex-rs/utils/cli/src/config_override.rs +++ b/codex-rs/utils/cli/src/config_override.rs @@ -37,6 +37,13 @@ pub struct CliConfigOverrides { } impl CliConfigOverrides { + /// Prepend root-level config flags so they have lower precedence than + /// command-specific flags parsed after a subcommand. + pub fn prepend_root_overrides(&mut self, root_overrides: Self) { + self.raw_overrides + .splice(0..0, root_overrides.raw_overrides); + } + /// Parse the raw strings captured from the CLI into a list of `(path, /// value)` tuples where `value` is a `serde_json::Value`. pub fn parse_overrides(&self) -> Result, String> { @@ -190,6 +197,24 @@ mod tests { assert_eq!(parsed[0].1.as_bool(), Some(true)); } + #[test] + fn prepends_root_overrides() { + let mut subcommand_overrides = CliConfigOverrides { + raw_overrides: vec![r#"model="gpt-5.2""#.to_string()], + }; + subcommand_overrides.prepend_root_overrides(CliConfigOverrides { + raw_overrides: vec![r#"model="gpt-5.1""#.to_string()], + }); + + assert_eq!( + subcommand_overrides.raw_overrides, + vec![ + r#"model="gpt-5.1""#.to_string(), + r#"model="gpt-5.2""#.to_string(), + ] + ); + } + #[test] fn parses_inline_table() { let v = parse_toml_value("{a = 1, b = 2}").expect("parse"); diff --git a/codex-rs/utils/cli/src/shared_options.rs b/codex-rs/utils/cli/src/shared_options.rs index c174a7b5a6..78b123f4e1 100644 --- a/codex-rs/utils/cli/src/shared_options.rs +++ b/codex-rs/utils/cli/src/shared_options.rs @@ -47,6 +47,11 @@ pub struct SharedCliOptions { )] pub dangerously_bypass_approvals_and_sandbox: bool, + /// Run enabled hooks without requiring persisted hook trust for this invocation. + /// DANGEROUS. Intended only for automation that already vets hook sources. + #[arg(long = "dangerously-bypass-hook-trust", default_value_t = false)] + pub bypass_hook_trust: bool, + /// Tell the agent to use the specified directory as its working root. #[clap(long = "cd", short = 'C', value_name = "DIR")] pub cwd: Option, @@ -68,6 +73,7 @@ impl SharedCliOptions { config_profile, sandbox_mode, dangerously_bypass_approvals_and_sandbox, + bypass_hook_trust, cwd, add_dir, } = self; @@ -79,6 +85,7 @@ impl SharedCliOptions { config_profile: root_config_profile, sandbox_mode: root_sandbox_mode, dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox, + bypass_hook_trust: root_bypass_hook_trust, cwd: root_cwd, add_dir: root_add_dir, } = root; @@ -102,6 +109,9 @@ impl SharedCliOptions { *dangerously_bypass_approvals_and_sandbox = *root_dangerously_bypass_approvals_and_sandbox; } + if !*bypass_hook_trust { + *bypass_hook_trust = *root_bypass_hook_trust; + } if cwd.is_none() { cwd.clone_from(root_cwd); } @@ -128,6 +138,7 @@ impl SharedCliOptions { config_profile, sandbox_mode, dangerously_bypass_approvals_and_sandbox, + bypass_hook_trust, cwd, add_dir, } = subcommand; @@ -149,6 +160,9 @@ impl SharedCliOptions { self.dangerously_bypass_approvals_and_sandbox = dangerously_bypass_approvals_and_sandbox; } + if bypass_hook_trust { + self.bypass_hook_trust = true; + } if let Some(cwd) = cwd { self.cwd = Some(cwd); } diff --git a/docs/config.md b/docs/config.md index 950a631fc5..d35b0a87e3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -5,3 +5,11 @@ For basic configuration instructions, see [this documentation](https://developer For advanced configuration instructions, see [this documentation](https://developers.openai.com/codex/config-advanced). For a full configuration reference, see [this documentation](https://developers.openai.com/codex/config-reference). + +## Lifecycle hooks + +Admins can set top-level `allow_managed_hooks_only = true` in +`requirements.toml` to ignore user, project, and session hook configs while +still allowing managed hooks from requirements and managed config layers. This +setting is only supported in `requirements.toml`; putting it in `config.toml` +does not enable managed-hooks-only mode. diff --git a/justfile b/justfile index 9297c372a3..ab2fbc6362 100644 --- a/justfile +++ b/justfile @@ -33,8 +33,8 @@ app-server-test-client *args: # Format Rust and Python SDK code. fmt: cargo fmt -- --config imports_granularity=Item 2>/dev/null - uv run --project ../sdk/python --extra dev ruff check --fix --fix-only ../sdk/python - uv run --project ../sdk/python --extra dev ruff format ../sdk/python + uv run --frozen --project ../sdk/python --extra dev ruff check --fix --fix-only ../sdk/python + uv run --frozen --project ../sdk/python --extra dev ruff format ../sdk/python fix *args: cargo clippy --fix --tests --allow-dirty "$@" diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 4a8e4df4d1..b43cdd5703 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -66,8 +66,8 @@ def test_root_fmt_recipe_formats_rust_and_python_sdk() -> None: "previous_attribute": "# Format Rust and Python SDK code.", "commands": [ "cargo fmt -- --config imports_granularity=Item 2>/dev/null", - "uv run --project ../sdk/python --extra dev ruff check --fix --fix-only ../sdk/python", - "uv run --project ../sdk/python --extra dev ruff format ../sdk/python", + "uv run --frozen --project ../sdk/python --extra dev ruff check --fix --fix-only ../sdk/python", + "uv run --frozen --project ../sdk/python --extra dev ruff format ../sdk/python", ], } diff --git a/sdk/typescript/tests/abort.test.ts b/sdk/typescript/tests/abort.test.ts index 0af318272b..ca93b1e89d 100644 --- a/sdk/typescript/tests/abort.test.ts +++ b/sdk/typescript/tests/abort.test.ts @@ -127,7 +127,7 @@ describe("AbortSignal support", () => { void event; // Consume the event eventCount++; // Abort after first event - if (eventCount === 5) { + if (eventCount === 1) { controller.abort("Aborted during iteration"); } // Continue iterating - should eventually throw diff --git a/sdk/typescript/tests/responsesProxy.ts b/sdk/typescript/tests/responsesProxy.ts index e9d3b29146..012cbf2320 100644 --- a/sdk/typescript/tests/responsesProxy.ts +++ b/sdk/typescript/tests/responsesProxy.ts @@ -181,15 +181,14 @@ export function assistantMessage(text: string, itemId: string = DEFAULT_MESSAGE_ } export function shell_call(): SseEvent { - const command = ["bash", "-lc", "echo 'Hello, world!'"]; return { type: "response.output_item.done", item: { type: "function_call", call_id: `call_id${Math.random().toString(36).slice(2)}`, - name: "shell", + name: "shell_command", arguments: JSON.stringify({ - command, + command: "echo 'Hello, world!'", timeout_ms: 100, }), },