From 67e802e26b7526d9aeceebb5faf748c04f13388d Mon Sep 17 00:00:00 2001 From: Curtis 'Fjord' Hawthorne Date: Fri, 20 Feb 2026 11:51:17 -0800 Subject: [PATCH 001/282] ci(bazel): install Node from node-version.txt in remote image (#12205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Install Node in the Bazel remote execution image using the version pinned in `codex-rs/node-version.txt`. ## Why `js_repl` tests run under Bazel remote execution and require a modern Node runtime. Runner-level `setup-node` does not guarantee Node is available (or recent enough) inside the remote worker container. ## What changed - Updated `.github/workflows/Dockerfile.bazel` to install Node from official tarballs at image build time. - Added `xz-utils` for extracting `.tar.xz` archives. - Copied `codex-rs/node-version.txt` into the image build context and used it as the single source of truth for Node version. - Added architecture mapping for multi-arch builds: - `amd64 -> x64` - `arm64 -> arm64` - Verified install during image build with: - `node --version` - `npm --version` ## Impact - Bazel remote workers should now have the required Node version available for `js_repl` tests. - Keeps Node version synchronized with repo policy via `codex-rs/node-version.txt`. ## Testing - Verified Dockerfile changes and build steps locally (build-time commands are deterministic and fail fast on unsupported arch/version fetch issues). ## Follow-up - Rebuild and publish the Bazel runner image for both `linux/amd64` and `linux/arm64`. - Update image digests in `rbe.bzl` to roll out this runtime update in CI. #### [git stack](https://github.com/magus/git-stack-cli) - ✅ `1` https://github.com/openai/codex/pull/12300 - ✅ `2` https://github.com/openai/codex/pull/12275 - 👉 `3` https://github.com/openai/codex/pull/12205 - ⏳ `4` https://github.com/openai/codex/pull/12185 - ⏳ `5` https://github.com/openai/codex/pull/10673 --- .github/workflows/Dockerfile.bazel | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/Dockerfile.bazel b/.github/workflows/Dockerfile.bazel index 51c199dcc3..4f85409f94 100644 --- a/.github/workflows/Dockerfile.bazel +++ b/.github/workflows/Dockerfile.bazel @@ -8,9 +8,25 @@ FROM ubuntu:24.04 RUN apt-get update && \ apt-get install -y --no-install-recommends \ - curl git python3 ca-certificates && \ + curl git python3 ca-certificates xz-utils && \ rm -rf /var/lib/apt/lists/* +COPY codex-rs/node-version.txt /tmp/node-version.txt + +RUN set -eux; \ + node_arch="$(dpkg --print-architecture)"; \ + case "${node_arch}" in \ + amd64) node_dist_arch="x64" ;; \ + arm64) node_dist_arch="arm64" ;; \ + *) echo "unsupported architecture: ${node_arch}"; exit 1 ;; \ + esac; \ + node_version="$(tr -d '[:space:]' Date: Fri, 20 Feb 2026 20:02:24 +0000 Subject: [PATCH 002/282] feat: add config `allow_login_shell` (#12312) --- codex-rs/core/config.schema.json | 4 + codex-rs/core/src/codex.rs | 3 + codex-rs/core/src/config/mod.rs | 46 ++++++++++ codex-rs/core/src/memories/usage.rs | 22 +++-- codex-rs/core/src/skills/loader.rs | 4 + codex-rs/core/src/skills/permissions.rs | 4 + codex-rs/core/src/tools/handlers/shell.rs | 87 ++++++++++++++++--- .../core/src/tools/handlers/unified_exec.rs | 69 ++++++++++++--- codex-rs/core/src/tools/spec.rs | 61 ++++++++----- 9 files changed, 244 insertions(+), 56 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 732f9590f7..97ce03cadf 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1392,6 +1392,10 @@ ], "description": "Agent-related settings (thread limits, etc.)." }, + "allow_login_shell": { + "description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.", + "type": "boolean" + }, "analytics": { "allOf": [ { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b5614cced5..2d5d283d6a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -609,6 +609,7 @@ impl TurnContext { features: &features, web_search_mode: self.tools_config.web_search_mode, }) + .with_allow_login_shell(self.tools_config.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); Self { @@ -941,6 +942,7 @@ impl Session { features: &per_turn_config.features, web_search_mode: Some(per_turn_config.web_search_mode.value()), }) + .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) .with_agent_roles(per_turn_config.agent_roles.clone()); let cwd = session_configuration.cwd.clone(); @@ -4122,6 +4124,7 @@ async fn spawn_review_thread( features: &review_features, web_search_mode: Some(review_web_search_mode), }) + .with_allow_login_shell(config.permissions.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); let review_prompt = resolved.prompt.clone(); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1675067c7a..3b8e0382e8 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -139,6 +139,15 @@ pub struct Permissions { pub sandbox_policy: Constrained, /// Effective network configuration applied to all spawned processes. pub network: Option, + /// Whether the model may request a login shell for shell-based tools. + /// Default to `true` + /// + /// If `true`, the model may request a login shell (`login = true`), and + /// omitting `login` defaults to using a login shell. + /// If `false`, the model can never use a login shell: `login = true` + /// requests are rejected, and omitting `login` defaults to a non-login + /// shell. + pub allow_login_shell: bool, /// Policy used to build process environments for shell/unified exec. pub shell_environment_policy: ShellEnvironmentPolicy, /// Effective Windows sandbox mode derived from `[windows].sandbox` or @@ -952,6 +961,16 @@ pub struct ConfigToml { #[serde(default)] pub shell_environment_policy: ShellEnvironmentPolicyToml, + /// Whether the model may request a login shell for shell-based tools. + /// Default to `true` + /// + /// If `true`, the model may request a login shell (`login = true`), and + /// omitting `login` defaults to using a login shell. + /// If `false`, the model can never use a login shell: `login = true` + /// requests are rejected, and omitting `login` defaults to a non-login + /// shell. + pub allow_login_shell: Option, + /// Sandbox mode to use. pub sandbox_mode: Option, @@ -1710,6 +1729,7 @@ impl Config { .clone(); let shell_environment_policy = cfg.shell_environment_policy.into(); + let allow_login_shell = cfg.allow_login_shell.unwrap_or(true); let history = cfg.history.unwrap_or_default(); @@ -1950,6 +1970,7 @@ impl Config { approval_policy: constrained_approval_policy.value, sandbox_policy: constrained_sandbox_policy.value, network, + allow_login_shell, shell_environment_policy, windows_sandbox_mode, macos_seatbelt_profile_extensions: None, @@ -4519,6 +4540,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -4637,6 +4659,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -4753,6 +4776,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -4855,6 +4879,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -5429,6 +5454,27 @@ mcp_oauth_callback_port = 5678 Ok(()) } + #[test] + fn config_loads_allow_login_shell_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg: ConfigToml = toml::from_str( + r#" +model = "gpt-5.1" +allow_login_shell = false +"#, + ) + .expect("TOML deserialization should succeed for allow_login_shell"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(!config.permissions.allow_login_shell); + Ok(()) + } + #[test] fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/memories/usage.rs b/codex-rs/core/src/memories/usage.rs index dbdcd25735..ff82d6e164 100644 --- a/codex-rs/core/src/memories/usage.rs +++ b/codex-rs/core/src/memories/usage.rs @@ -84,22 +84,28 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec serde_json::from_str::(arguments) .ok() .map(|params| { + if !invocation.turn.tools_config.allow_login_shell && params.login == Some(true) { + return (Vec::new(), invocation.turn.resolve_path(params.workdir)); + } + let use_login_shell = params + .login + .unwrap_or(invocation.turn.tools_config.allow_login_shell); let command = invocation .session .user_shell() - .derive_exec_args(¶ms.command, params.login.unwrap_or(true)); + .derive_exec_args(¶ms.command, use_login_shell); (command, invocation.turn.resolve_path(params.workdir)) }), "exec_command" => serde_json::from_str::(arguments) .ok() - .map(|params| { - ( - crate::tools::handlers::unified_exec::get_command( - ¶ms, - invocation.session.user_shell(), - ), - invocation.turn.resolve_path(params.workdir), + .and_then(|params| { + let command = crate::tools::handlers::unified_exec::get_command( + ¶ms, + invocation.session.user_shell(), + invocation.turn.tools_config.allow_login_shell, ) + .ok()?; + Some((command, invocation.turn.resolve_path(params.workdir))) }), _ => None, } diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index e00bd42976..3c497bfcf8 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1372,6 +1372,7 @@ permissions: } ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions, @@ -1408,6 +1409,7 @@ permissions: {} crate::protocol::SandboxPolicy::new_read_only_policy(), ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: Some( @@ -1421,6 +1423,7 @@ permissions: {} crate::protocol::SandboxPolicy::new_read_only_policy(), ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -1515,6 +1518,7 @@ permissions: crate::protocol::SandboxPolicy::new_read_only_policy(), ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, diff --git a/codex-rs/core/src/skills/permissions.rs b/codex-rs/core/src/skills/permissions.rs index 1cf18e1052..24f3c9444c 100644 --- a/codex-rs/core/src/skills/permissions.rs +++ b/codex-rs/core/src/skills/permissions.rs @@ -112,6 +112,7 @@ pub(crate) fn compile_permission_profile( approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(sandbox_policy), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions, @@ -347,6 +348,7 @@ mod tests { exclude_slash_tmp: false, }), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, #[cfg(target_os = "macos")] @@ -391,6 +393,7 @@ mod tests { approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, #[cfg(target_os = "macos")] @@ -439,6 +442,7 @@ mod tests { }, }), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, #[cfg(target_os = "macos")] diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 8ca6de0d80..f040b4ba64 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -63,8 +63,20 @@ impl ShellHandler { } impl ShellCommandHandler { - fn base_command(shell: &Shell, command: &str, login: Option) -> Vec { - let use_login_shell = login.unwrap_or(true); + fn resolve_use_login_shell( + login: Option, + allow_login_shell: bool, + ) -> Result { + if !allow_login_shell && login == Some(true) { + return Err(FunctionCallError::RespondToModel( + "login shell is disabled by config; omit `login` or set it to false.".to_string(), + )); + } + + Ok(login.unwrap_or(allow_login_shell)) + } + + fn base_command(shell: &Shell, command: &str, use_login_shell: bool) -> Vec { shell.derive_exec_args(command, use_login_shell) } @@ -73,11 +85,13 @@ impl ShellCommandHandler { session: &crate::codex::Session, turn_context: &TurnContext, thread_id: ThreadId, - ) -> ExecParams { + allow_login_shell: bool, + ) -> Result { let shell = session.user_shell(); - let command = Self::base_command(shell.as_ref(), ¶ms.command, params.login); + let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?; + let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); - ExecParams { + Ok(ExecParams { command, cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), @@ -87,7 +101,7 @@ impl ShellCommandHandler { windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), arg0: None, - } + }) } } @@ -183,8 +197,15 @@ impl ToolHandler for ShellCommandHandler { 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, params.login); + let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); !is_known_safe_command(&command) }) .unwrap_or(true) @@ -213,7 +234,8 @@ impl ToolHandler for ShellCommandHandler { session.as_ref(), turn.as_ref(), session.conversation_id, - ); + turn.tools_config.allow_login_shell, + )?; ShellHandler::run_exec_like(RunExecLikeArgs { tool_name, exec_params, @@ -439,7 +461,9 @@ mod tests { &session, &turn_context, session.conversation_id, - ); + true, + ) + .expect("login shells should be allowed"); // ExecParams cannot derive Eq due to the CancellationToken field, so we manually compare the fields. assert_eq!(exec_params.command, expected_command); @@ -464,18 +488,57 @@ mod tests { shell_snapshot, }; - let login_command = - ShellCommandHandler::base_command(&shell, "echo login shell", Some(true)); + let login_command = ShellCommandHandler::base_command(&shell, "echo login shell", true); assert_eq!( login_command, shell.derive_exec_args("echo login shell", true) ); let non_login_command = - ShellCommandHandler::base_command(&shell, "echo non login shell", Some(false)); + ShellCommandHandler::base_command(&shell, "echo non login shell", false); assert_eq!( non_login_command, shell.derive_exec_args("echo non login shell", false) ); } + + #[tokio::test] + async fn shell_command_handler_defaults_to_non_login_when_disallowed() { + let (session, turn_context) = make_session_and_context().await; + let params = ShellCommandToolCallParams { + command: "echo hello".to_string(), + workdir: None, + login: None, + timeout_ms: None, + sandbox_permissions: None, + prefix_rule: None, + justification: None, + }; + + let exec_params = ShellCommandHandler::to_exec_params( + ¶ms, + &session, + &turn_context, + session.conversation_id, + false, + ) + .expect("non-login shells should still be allowed"); + + assert_eq!( + exec_params.command, + session.user_shell().derive_exec_args("echo hello", false) + ); + } + + #[test] + fn shell_command_handler_rejects_login_when_disallowed() { + let err = ShellCommandHandler::resolve_use_login_shell(Some(true), false) + .expect_err("explicit login should be rejected"); + + assert!( + err.to_string() + .contains("login shell is disabled by config"), + "unexpected error: {err}" + ); + } } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 1bad6fa638..62da64a877 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -32,8 +32,8 @@ pub(crate) struct ExecCommandArgs { pub(crate) workdir: Option, #[serde(default)] shell: Option, - #[serde(default = "default_login")] - login: bool, + #[serde(default)] + login: Option, #[serde(default = "default_tty")] tty: bool, #[serde(default = "default_exec_yield_time_ms")] @@ -68,10 +68,6 @@ fn default_write_stdin_yield_time_ms() -> u64 { 250 } -fn default_login() -> bool { - true -} - fn default_tty() -> bool { false } @@ -98,7 +94,14 @@ impl ToolHandler for UnifiedExecHandler { let Ok(params) = serde_json::from_str::(arguments) else { return true; }; - let command = get_command(¶ms, invocation.session.user_shell()); + let command = match get_command( + ¶ms, + invocation.session.user_shell(), + invocation.turn.tools_config.allow_login_shell, + ) { + Ok(command) => command, + Err(_) => return true, + }; !is_known_safe_command(&command) } @@ -129,7 +132,12 @@ impl ToolHandler for UnifiedExecHandler { "exec_command" => { let args: ExecCommandArgs = parse_arguments(&arguments)?; let process_id = manager.allocate_process_id().await; - let command = get_command(&args, session.user_shell()); + let command = get_command( + &args, + session.user_shell(), + turn.tools_config.allow_login_shell, + ) + .map_err(FunctionCallError::RespondToModel)?; let ExecCommandArgs { workdir, @@ -238,7 +246,11 @@ impl ToolHandler for UnifiedExecHandler { } } -pub(crate) fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> Vec { +pub(crate) fn get_command( + args: &ExecCommandArgs, + session_shell: Arc, + allow_login_shell: bool, +) -> Result, String> { let model_shell = args.shell.as_ref().map(|shell_str| { let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str)); shell.shell_snapshot = crate::shell::empty_shell_snapshot_receiver(); @@ -246,8 +258,17 @@ pub(crate) fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> }); let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref()); + let use_login_shell = match args.login { + Some(true) if !allow_login_shell => { + return Err( + "login shell is disabled by config; omit `login` or set it to false.".to_string(), + ); + } + Some(use_login_shell) => use_login_shell, + None => allow_login_shell, + }; - shell.derive_exec_args(&args.cmd, args.login) + Ok(shell.derive_exec_args(&args.cmd, use_login_shell)) } fn format_response(response: &UnifiedExecResponse) -> String { @@ -283,6 +304,7 @@ fn format_response(response: &UnifiedExecResponse) -> String { mod tests { use super::*; use crate::shell::default_user_shell; + use pretty_assertions::assert_eq; use std::sync::Arc; #[test] @@ -293,7 +315,8 @@ mod tests { assert!(args.shell.is_none()); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command.len(), 3); assert_eq!(command[2], "echo hello"); @@ -308,7 +331,8 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command.last(), Some(&"echo hello".to_string())); if command @@ -328,7 +352,8 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("powershell")); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command[2], "echo hello"); Ok(()) @@ -342,9 +367,25 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("cmd")); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command[2], "echo hello"); Ok(()) } + + #[test] + fn test_get_command_rejects_explicit_login_when_disallowed() -> anyhow::Result<()> { + let json = r#"{"cmd": "echo hello", "login": true}"#; + + let args: ExecCommandArgs = parse_arguments(json)?; + let err = get_command(&args, Arc::new(default_user_shell()), false) + .expect_err("explicit login should be rejected"); + + assert!( + err.contains("login shell is disabled by config"), + "unexpected error: {err}" + ); + Ok(()) + } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index fe9c7ee8a5..7bd1fc9788 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -35,6 +35,7 @@ const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str = #[derive(Debug, Clone)] pub(crate) struct ToolsConfig { pub shell_type: ConfigShellToolType, + pub allow_login_shell: bool, pub apply_patch_tool_type: Option, pub web_search_mode: Option, pub agent_roles: BTreeMap, @@ -96,6 +97,7 @@ impl ToolsConfig { Self { shell_type, + allow_login_shell: true, apply_patch_tool_type, web_search_mode: *web_search_mode, agent_roles: BTreeMap::new(), @@ -112,6 +114,11 @@ impl ToolsConfig { self.agent_roles = agent_roles; self } + + pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self { + self.allow_login_shell = allow_login_shell; + self + } } /// Generic JSON‑Schema subset needed for our tool definitions @@ -211,7 +218,7 @@ fn create_approval_parameters() -> BTreeMap { properties } -fn create_exec_command_tool() -> ToolSpec { +fn create_exec_command_tool(allow_login_shell: bool) -> ToolSpec { let mut properties = BTreeMap::from([ ( "cmd".to_string(), @@ -234,14 +241,6 @@ fn create_exec_command_tool() -> ToolSpec { description: Some("Shell binary to launch. Defaults to the user's default shell.".to_string()), }, ), - ( - "login".to_string(), - JsonSchema::Boolean { - description: Some( - "Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(), - ), - }, - ), ( "tty".to_string(), JsonSchema::Boolean { @@ -269,6 +268,16 @@ fn create_exec_command_tool() -> ToolSpec { }, ), ]); + if allow_login_shell { + properties.insert( + "login".to_string(), + JsonSchema::Boolean { + description: Some( + "Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(), + ), + }, + ); + } properties.extend(create_approval_parameters()); ToolSpec::Function(ResponsesApiTool { @@ -385,7 +394,7 @@ Examples of valid command strings: }) } -fn create_shell_command_tool() -> ToolSpec { +fn create_shell_command_tool(allow_login_shell: bool) -> ToolSpec { let mut properties = BTreeMap::from([ ( "command".to_string(), @@ -402,6 +411,14 @@ fn create_shell_command_tool() -> ToolSpec { }, ), ( + "timeout_ms".to_string(), + JsonSchema::Number { + description: Some("The timeout for the command in milliseconds".to_string()), + }, + ), + ]); + if allow_login_shell { + properties.insert( "login".to_string(), JsonSchema::Boolean { description: Some( @@ -409,14 +426,8 @@ fn create_shell_command_tool() -> ToolSpec { .to_string(), ), }, - ), - ( - "timeout_ms".to_string(), - JsonSchema::Number { - description: Some("The timeout for the command in milliseconds".to_string()), - }, - ), - ]); + ); + } properties.extend(create_approval_parameters()); let description = if cfg!(windows) { @@ -1462,7 +1473,10 @@ pub(crate) fn build_specs( builder.push_spec_with_parallel_support(ToolSpec::LocalShell {}, true); } ConfigShellToolType::UnifiedExec => { - builder.push_spec_with_parallel_support(create_exec_command_tool(), true); + builder.push_spec_with_parallel_support( + create_exec_command_tool(config.allow_login_shell), + true, + ); builder.push_spec(create_write_stdin_tool()); builder.register_handler("exec_command", unified_exec_handler.clone()); builder.register_handler("write_stdin", unified_exec_handler); @@ -1471,7 +1485,10 @@ pub(crate) fn build_specs( // Do nothing. } ConfigShellToolType::ShellCommand => { - builder.push_spec_with_parallel_support(create_shell_command_tool(), true); + builder.push_spec_with_parallel_support( + create_shell_command_tool(config.allow_login_shell), + true, + ); } } @@ -1816,7 +1833,7 @@ mod tests { // Build expected from the same helpers used by the builder. let mut expected: BTreeMap = BTreeMap::from([]); for spec in [ - create_exec_command_tool(), + create_exec_command_tool(true), create_write_stdin_tool(), PLAN_TOOL.clone(), create_request_user_input_tool(), @@ -2790,7 +2807,7 @@ Examples of valid command strings: #[test] fn test_shell_command_tool() { - let tool = super::create_shell_command_tool(); + let tool = super::create_shell_command_tool(true); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool From aa121a115e257cff4ace1075e1a0411d48f22a59 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 20 Feb 2026 12:05:21 -0800 Subject: [PATCH 003/282] [apps] Implement apps configs. (#12086) - [x] Implement apps configs. --- .../codex_app_server_protocol.schemas.json | 101 +++- .../schema/json/v2/ConfigReadResponse.json | 103 +++- ...ppDisabledReason.ts => AppToolApproval.ts} | 2 +- .../schema/typescript/v2/AppToolsConfig.ts | 6 + .../schema/typescript/v2/AppsConfig.ts | 6 +- .../schema/typescript/v2/AppsDefaultConfig.ts | 5 + .../schema/typescript/v2/index.ts | 4 +- .../app-server-protocol/src/protocol/v2.rs | 44 +- .../app-server/tests/suite/v2/config_rpc.rs | 23 +- codex-rs/core/config.schema.json | 89 +++- codex-rs/core/src/codex.rs | 19 +- codex-rs/core/src/config/service.rs | 15 +- codex-rs/core/src/config/types.rs | 84 ++- codex-rs/core/src/connectors.rs | 488 +++++++++++++++++- codex-rs/core/src/mcp_tool_call.rs | 159 ++++-- .../src/tools/handlers/search_tool_bm25.rs | 1 + 16 files changed, 1043 insertions(+), 106 deletions(-) rename codex-rs/app-server-protocol/schema/typescript/v2/{AppDisabledReason.ts => AppToolApproval.ts} (71%) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts 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 5a840f0bde..7a01b04b51 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 @@ -10665,30 +10665,51 @@ }, "AppConfig": { "properties": { - "disabled_reason": { + "default_tools_approval_mode": { "anyOf": [ { - "$ref": "#/definitions/v2/AppDisabledReason" + "$ref": "#/definitions/v2/AppToolApproval" }, { "type": "null" } ] }, + "default_tools_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "destructive_enabled": { + "type": [ + "boolean", + "null" + ] + }, "enabled": { "default": true, "type": "boolean" + }, + "open_world_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppToolsConfig" + }, + { + "type": "null" + } + ] } }, "type": "object" }, - "AppDisabledReason": { - "enum": [ - "unknown", - "user" - ], - "type": "string" - }, "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { @@ -10914,7 +10935,69 @@ ], "type": "object" }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "properties": { + "approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AppToolsConfig": { + "type": "object" + }, "AppsConfig": { + "properties": { + "_default": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppsDefaultConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "AppsDefaultConfig": { + "properties": { + "destructive_enabled": { + "default": true, + "type": "boolean" + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "default": true, + "type": "boolean" + } + }, "type": "object" }, "AppsListParams": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 293fe7dc31..4511515d17 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -19,10 +19,65 @@ }, "AppConfig": { "properties": { - "disabled_reason": { + "default_tools_approval_mode": { "anyOf": [ { - "$ref": "#/definitions/AppDisabledReason" + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "default_tools_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "destructive_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolsConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "properties": { + "approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" }, { "type": "null" @@ -30,20 +85,48 @@ ] }, "enabled": { - "default": true, - "type": "boolean" + "type": [ + "boolean", + "null" + ] } }, "type": "object" }, - "AppDisabledReason": { - "enum": [ - "unknown", - "user" - ], - "type": "string" + "AppToolsConfig": { + "type": "object" }, "AppsConfig": { + "properties": { + "_default": { + "anyOf": [ + { + "$ref": "#/definitions/AppsDefaultConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "AppsDefaultConfig": { + "properties": { + "destructive_enabled": { + "default": true, + "type": "boolean" + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "default": true, + "type": "boolean" + } + }, "type": "object" }, "AskForApproval": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts similarity index 71% rename from codex-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts index bc44835a76..e92cd8e28b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AppDisabledReason = "unknown" | "user"; +export type AppToolApproval = "auto" | "prompt" | "approve"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.ts new file mode 100644 index 0000000000..16a1c22c6d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.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 { AppToolApproval } from "./AppToolApproval"; + +export type AppToolsConfig = { [key in string]?: { enabled: boolean | null, approval_mode: AppToolApproval | null, } }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts index ae3e455201..b22997de97 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts @@ -1,6 +1,8 @@ // 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 { AppDisabledReason } from "./AppDisabledReason"; +import type { AppToolApproval } from "./AppToolApproval"; +import type { AppToolsConfig } from "./AppToolsConfig"; +import type { AppsDefaultConfig } from "./AppsDefaultConfig"; -export type AppsConfig = { [key in string]?: { enabled: boolean, disabled_reason: AppDisabledReason | null, } }; +export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, } }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts new file mode 100644 index 0000000000..e73386027e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.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 AppsDefaultConfig = { enabled: boolean, destructive_enabled: boolean, open_world_enabled: boolean, }; 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 db94051fa6..c2f8494785 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -7,13 +7,15 @@ export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification"; export type { AnalyticsConfig } from "./AnalyticsConfig"; export type { AppBranding } from "./AppBranding"; -export type { AppDisabledReason } from "./AppDisabledReason"; export type { AppInfo } from "./AppInfo"; export type { AppListUpdatedNotification } from "./AppListUpdatedNotification"; export type { AppMetadata } from "./AppMetadata"; export type { AppReview } from "./AppReview"; export type { AppScreenshot } from "./AppScreenshot"; +export type { AppToolApproval } from "./AppToolApproval"; +export type { AppToolsConfig } from "./AppToolsConfig"; export type { AppsConfig } from "./AppsConfig"; +export type { AppsDefaultConfig } from "./AppsDefaultConfig"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; export type { AskForApproval } from "./AskForApproval"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2641d7c2a3..103b5f56a0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -398,12 +398,41 @@ pub struct AnalyticsConfig { pub additional: HashMap, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum AppToolApproval { + Auto, + Prompt, + Approve, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] -pub enum AppDisabledReason { - Unknown, - User, +pub struct AppsDefaultConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default = "default_enabled")] + pub destructive_enabled: bool, + #[serde(default = "default_enabled")] + pub open_world_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolConfig { + pub enabled: Option, + pub approval_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolsConfig { + #[serde(default, flatten)] + pub tools: HashMap, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -412,15 +441,20 @@ pub enum AppDisabledReason { pub struct AppConfig { #[serde(default = "default_enabled")] pub enabled: bool, - pub disabled_reason: Option, + pub destructive_enabled: Option, + pub open_world_enabled: Option, + pub default_tools_approval_mode: Option, + pub default_tools_enabled: Option, + pub tools: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] pub struct AppsConfig { + #[serde(default, rename = "_default")] + pub default: Option, #[serde(default, flatten)] - #[schemars(with = "HashMap")] pub apps: HashMap, } diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index de4c51cde4..cd74710876 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -4,7 +4,7 @@ use app_test_support::test_path_buf_with_windows; use app_test_support::test_tmp_path_buf; use app_test_support::to_response; use codex_app_server_protocol::AppConfig; -use codex_app_server_protocol::AppDisabledReason; +use codex_app_server_protocol::AppToolApproval; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -156,7 +156,8 @@ async fn config_read_includes_apps() -> Result<()> { r#" [apps.app1] enabled = false -disabled_reason = "user" +destructive_enabled = false +default_tools_approval_mode = "prompt" "#, )?; let codex_home_path = codex_home.path().canonicalize()?; @@ -185,11 +186,16 @@ disabled_reason = "user" assert_eq!( config.apps, Some(AppsConfig { + default: None, apps: std::collections::HashMap::from([( "app1".to_string(), AppConfig { enabled: false, - disabled_reason: Some(AppDisabledReason::User), + destructive_enabled: Some(false), + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: None, }, )]), }) @@ -202,7 +208,16 @@ disabled_reason = "user" ); assert_eq!( origins - .get("apps.app1.disabled_reason") + .get("apps.app1.destructive_enabled") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + assert_eq!( + origins + .get("apps.app1.default_tools_approval_mode") .expect("origin") .name, ConfigLayerSource::User { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 97ce03cadf..e73a71bb30 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -85,34 +85,111 @@ "additionalProperties": false, "description": "Config values for a single app/connector.", "properties": { - "disabled_reason": { + "default_tools_approval_mode": { "allOf": [ { - "$ref": "#/definitions/AppDisabledReason" + "$ref": "#/definitions/AppToolApproval" } ], - "description": "Reason this app was disabled." + "description": "Approval mode for tools in this app unless a tool override exists." + }, + "default_tools_enabled": { + "description": "Whether tools are enabled by default for this app.", + "type": "boolean" + }, + "destructive_enabled": { + "description": "Whether tools with `destructive_hint = true` are allowed for this app.", + "type": "boolean" }, "enabled": { "default": true, "description": "When `false`, Codex does not surface this app.", "type": "boolean" + }, + "open_world_enabled": { + "description": "Whether tools with `open_world_hint = true` are allowed for this app.", + "type": "boolean" + }, + "tools": { + "allOf": [ + { + "$ref": "#/definitions/AppToolsConfig" + } + ], + "description": "Per-tool settings for this app." } }, "type": "object" }, - "AppDisabledReason": { + "AppToolApproval": { "enum": [ - "unknown", - "user" + "auto", + "prompt", + "approve" ], "type": "string" }, + "AppToolConfig": { + "additionalProperties": false, + "description": "Per-tool settings for a single app tool.", + "properties": { + "approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Approval mode for this tool." + }, + "enabled": { + "description": "Whether this tool is enabled. `Some(true)` explicitly allows this tool.", + "type": "boolean" + } + }, + "type": "object" + }, + "AppToolsConfig": { + "additionalProperties": { + "$ref": "#/definitions/AppToolConfig" + }, + "description": "Tool settings for a single app.", + "type": "object" + }, "AppsConfigToml": { "additionalProperties": { "$ref": "#/definitions/AppConfig" }, "description": "App/connector settings loaded from `config.toml`.", + "properties": { + "_default": { + "allOf": [ + { + "$ref": "#/definitions/AppsDefaultConfig" + } + ], + "description": "Default settings for all apps." + } + }, + "type": "object" + }, + "AppsDefaultConfig": { + "additionalProperties": false, + "description": "Default settings that apply to all apps.", + "properties": { + "destructive_enabled": { + "description": "Whether tools with `destructive_hint = true` are allowed by default.", + "type": "boolean" + }, + "enabled": { + "default": true, + "description": "When `false`, apps are disabled unless overridden by per-app settings.", + "type": "boolean" + }, + "open_world_enabled": { + "description": "Whether tools with `open_world_hint = true` are allowed by default.", + "type": "boolean" + } + }, "type": "object" }, "AskForApproval": { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2d5d283d6a..fcddf4e83e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4834,6 +4834,7 @@ fn connector_inserted_in_messages( fn filter_codex_apps_mcp_tools( mcp_tools: &HashMap, connectors: &[connectors::AppInfo], + config: &Config, ) -> HashMap { let allowed: HashSet<&str> = connectors .iter() @@ -4849,7 +4850,7 @@ fn filter_codex_apps_mcp_tools( let Some(connector_id) = codex_apps_connector_id(tool) else { return false; }; - allowed.contains(connector_id) + allowed.contains(connector_id) && connectors::codex_app_tool_is_enabled(config, tool) }) .map(|(name, tool)| (name.clone(), tool.clone())) .collect() @@ -5023,9 +5024,9 @@ async fn built_tools( None }; - let app_tools = connectors - .as_ref() - .map(|connectors| filter_codex_apps_mcp_tools(&mcp_tools, connectors)); + let app_tools = connectors.as_ref().map(|connectors| { + filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config) + }); if let Some(connectors) = connectors.as_ref() { let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| { @@ -5050,7 +5051,8 @@ async fn built_tools( explicitly_enabled.as_ref(), )); - mcp_tools = selected_mcp_tools; + mcp_tools = + connectors::filter_codex_apps_tools_by_policy(selected_mcp_tools, &turn_context.config); } Ok(Arc::new(ToolRouter::from_config( @@ -6001,7 +6003,7 @@ mod tests { let config_toml_path = codex_home.join(CONFIG_TOML_FILE); std::fs::write( &config_toml_path, - "[apps.calendar]\nenabled = false\ndisabled_reason = \"user\"\n", + "[apps.calendar]\nenabled = false\ndestructive_enabled = false\n", ) .expect("write user config"); @@ -6023,10 +6025,7 @@ mod tests { .expect("calendar app config exists"); assert!(!app.enabled); - assert_eq!( - app.disabled_reason, - Some(crate::config::types::AppDisabledReason::User) - ); + assert_eq!(app.destructive_enabled, Some(false)); } #[test] diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 02a9da23fd..1033db728d 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -701,7 +701,7 @@ mod tests { use super::*; use anyhow::Result; use codex_app_server_protocol::AppConfig; - use codex_app_server_protocol::AppDisabledReason; + use codex_app_server_protocol::AppToolApproval; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; @@ -827,13 +827,13 @@ personality = true service .write_value(ConfigValueWriteParams { file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "apps.app1.disabled_reason".to_string(), - value: serde_json::json!("user"), + key_path: "apps.app1.default_tools_approval_mode".to_string(), + value: serde_json::json!("prompt"), merge_strategy: MergeStrategy::Replace, expected_version: None, }) .await - .expect("write apps.app1.disabled_reason succeeds"); + .expect("write apps.app1.default_tools_approval_mode succeeds"); let read = service .read(ConfigReadParams { @@ -846,11 +846,16 @@ personality = true assert_eq!( read.config.apps, Some(AppsConfig { + default: None, apps: std::collections::HashMap::from([( "app1".to_string(), AppConfig { enabled: false, - disabled_reason: Some(AppDisabledReason::User), + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: None, }, )]), }) diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index ca90414c27..2e3573f770 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -425,20 +425,58 @@ impl From for MemoriesConfig { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)] #[serde(rename_all = "snake_case")] -pub enum AppDisabledReason { - Unknown, - User, +pub enum AppToolApproval { + #[default] + Auto, + Prompt, + Approve, } -impl fmt::Display for AppDisabledReason { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - AppDisabledReason::Unknown => write!(f, "unknown"), - AppDisabledReason::User => write!(f, "user"), - } - } +/// Default settings that apply to all apps. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppsDefaultConfig { + /// When `false`, apps are disabled unless overridden by per-app settings. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Whether tools with `destructive_hint = true` are allowed by default. + #[serde( + default = "default_enabled", + skip_serializing_if = "std::clone::Clone::clone" + )] + pub destructive_enabled: bool, + + /// Whether tools with `open_world_hint = true` are allowed by default. + #[serde( + default = "default_enabled", + skip_serializing_if = "std::clone::Clone::clone" + )] + pub open_world_enabled: bool, +} + +/// Per-tool settings for a single app tool. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolConfig { + /// Whether this tool is enabled. `Some(true)` explicitly allows this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + + /// Approval mode for this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_mode: Option, +} + +/// Tool settings for a single app. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolsConfig { + /// Per-tool overrides keyed by tool name (for example `repos/list`). + #[serde(default, flatten)] + pub tools: HashMap, } /// Config values for a single app/connector. @@ -449,15 +487,35 @@ pub struct AppConfig { #[serde(default = "default_enabled")] pub enabled: bool, - /// Reason this app was disabled. + /// Whether tools with `destructive_hint = true` are allowed for this app. #[serde(default, skip_serializing_if = "Option::is_none")] - pub disabled_reason: Option, + pub destructive_enabled: Option, + + /// Whether tools with `open_world_hint = true` are allowed for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_world_enabled: Option, + + /// Approval mode for tools in this app unless a tool override exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, + + /// Whether tools are enabled by default for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_enabled: Option, + + /// Per-tool settings for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option, } /// App/connector settings loaded from `config.toml`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct AppsConfigToml { + /// Default settings for all apps. + #[serde(default, rename = "_default", skip_serializing_if = "Option::is_none")] + pub default: Option, + /// Per-app settings keyed by app ID (for example `[apps.google_drive]`). #[serde(default, flatten)] pub apps: HashMap, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index a34477850d..33b003770e 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -11,6 +11,7 @@ pub use codex_app_server_protocol::AppBranding; pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; use codex_protocol::protocol::SandboxPolicy; +use rmcp::model::ToolAnnotations; use serde::Deserialize; use tracing::warn; @@ -18,6 +19,7 @@ use crate::AuthManager; use crate::CodexAuth; use crate::SandboxState; use crate::config::Config; +use crate::config::types::AppToolApproval; use crate::config::types::AppsConfigToml; use crate::default_client::is_first_party_chat_originator; use crate::default_client::originator; @@ -32,6 +34,21 @@ use crate::token_data::TokenData; pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AppToolPolicy { + pub enabled: bool, + pub approval: AppToolApproval, +} + +impl Default for AppToolPolicy { + fn default() -> Self { + Self { + enabled: true, + approval: AppToolApproval::Auto, + } + } +} + #[derive(Clone, PartialEq, Eq)] struct AccessibleConnectorsCacheKey { chatgpt_base_url: String, @@ -337,15 +354,58 @@ pub fn merge_connectors( } pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { - let apps = read_apps_config(config).map(|apps_config| apps_config.apps); - for connector in &mut connectors { - if let Some(app) = apps.as_ref().and_then(|apps| apps.get(&connector.id)) { - connector.is_enabled = app.enabled; + let apps_config = read_apps_config(config); + if let Some(apps_config) = apps_config.as_ref() { + for connector in &mut connectors { + connector.is_enabled = app_is_enabled(apps_config, Some(connector.id.as_str())); } } connectors } +pub(crate) fn app_tool_policy( + config: &Config, + connector_id: Option<&str>, + tool_name: &str, + tool_title: Option<&str>, + annotations: Option<&ToolAnnotations>, +) -> AppToolPolicy { + let apps_config = read_apps_config(config); + app_tool_policy_from_apps_config( + apps_config.as_ref(), + connector_id, + tool_name, + tool_title, + annotations, + ) +} + +pub(crate) fn codex_app_tool_is_enabled( + config: &Config, + tool_info: &crate::mcp_connection_manager::ToolInfo, +) -> bool { + if tool_info.server_name != CODEX_APPS_MCP_SERVER_NAME { + return true; + } + + app_tool_policy( + config, + tool_info.connector_id.as_deref(), + &tool_info.tool_name, + tool_info.tool.title.as_deref(), + tool_info.tool.annotations.as_ref(), + ) + .enabled +} + +pub(crate) fn filter_codex_apps_tools_by_policy( + mut mcp_tools: HashMap, + config: &Config, +) -> HashMap { + mcp_tools.retain(|_, tool_info| codex_app_tool_is_enabled(config, tool_info)); + mcp_tools +} + const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ "asdk_app_6938a94a61d881918ef32cb999ff937c", "connector_2b0a9009c9c64bf9933a3dae3f2b1254", @@ -394,6 +454,85 @@ fn read_apps_config(config: &Config) -> Option { AppsConfigToml::deserialize(apps_config).ok() } +fn app_is_enabled(apps_config: &AppsConfigToml, connector_id: Option<&str>) -> bool { + let default_enabled = apps_config + .default + .as_ref() + .map(|defaults| defaults.enabled) + .unwrap_or(true); + + connector_id + .and_then(|connector_id| apps_config.apps.get(connector_id)) + .map(|app| app.enabled) + .unwrap_or(default_enabled) +} + +fn app_tool_policy_from_apps_config( + apps_config: Option<&AppsConfigToml>, + connector_id: Option<&str>, + tool_name: &str, + tool_title: Option<&str>, + annotations: Option<&ToolAnnotations>, +) -> AppToolPolicy { + let Some(apps_config) = apps_config else { + return AppToolPolicy::default(); + }; + + let app = connector_id.and_then(|connector_id| apps_config.apps.get(connector_id)); + let tools = app.and_then(|app| app.tools.as_ref()); + let tool_config = tools.and_then(|tools| { + tools + .tools + .get(tool_name) + .or_else(|| tool_title.and_then(|title| tools.tools.get(title))) + }); + let approval = tool_config + .and_then(|tool| tool.approval_mode) + .or_else(|| app.and_then(|app| app.default_tools_approval_mode)) + .unwrap_or(AppToolApproval::Auto); + + if !app_is_enabled(apps_config, connector_id) { + return AppToolPolicy { + enabled: false, + approval, + }; + } + + if let Some(enabled) = tool_config.and_then(|tool| tool.enabled) { + return AppToolPolicy { enabled, approval }; + } + + if let Some(enabled) = app.and_then(|app| app.default_tools_enabled) { + return AppToolPolicy { enabled, approval }; + } + + let app_defaults = apps_config.default.as_ref(); + let destructive_enabled = app + .and_then(|app| app.destructive_enabled) + .unwrap_or_else(|| { + app_defaults + .map(|defaults| defaults.destructive_enabled) + .unwrap_or(true) + }); + let open_world_enabled = app + .and_then(|app| app.open_world_enabled) + .unwrap_or_else(|| { + app_defaults + .map(|defaults| defaults.open_world_enabled) + .unwrap_or(true) + }); + let destructive_hint = annotations + .and_then(|annotations| annotations.destructive_hint) + .unwrap_or(false); + let open_world_hint = annotations + .and_then(|annotations| annotations.open_world_hint) + .unwrap_or(false); + let enabled = + (destructive_enabled || !destructive_hint) && (open_world_enabled || !open_world_hint); + + AppToolPolicy { enabled, approval } +} + fn collect_accessible_connectors(tools: I) -> Vec where I: IntoIterator)>, @@ -472,8 +611,25 @@ fn format_connector_label(name: &str, _id: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::config::types::AppConfig; + use crate::config::types::AppToolConfig; + use crate::config::types::AppToolsConfig; + use crate::config::types::AppsDefaultConfig; use pretty_assertions::assert_eq; + fn annotations( + destructive_hint: Option, + open_world_hint: Option, + ) -> ToolAnnotations { + ToolAnnotations { + destructive_hint, + idempotent_hint: None, + open_world_hint, + read_only_hint: None, + title: None, + } + } + fn app(id: &str) -> AppInfo { AppInfo { id: id.to_string(), @@ -491,6 +647,328 @@ mod tests { } } + #[test] + fn app_tool_policy_uses_global_defaults_for_destructive_hints() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: true, + destructive_enabled: false, + open_world_enabled: true, + }), + apps: HashMap::new(), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/create", + None, + Some(&annotations(Some(true), None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_is_enabled_uses_default_for_unconfigured_apps() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::new(), + }; + + assert!(!app_is_enabled(&apps_config, Some("calendar"))); + assert!(!app_is_enabled(&apps_config, None)); + } + + #[test] + fn app_is_enabled_prefers_per_app_override_over_default() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: None, + }, + )]), + }; + + assert!(app_is_enabled(&apps_config, Some("calendar"))); + assert!(!app_is_enabled(&apps_config, Some("drive"))); + } + + #[test] + fn app_tool_policy_honors_default_app_enabled_false() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::new(), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: None, + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_tool_policy_per_tool_enabled_true_overrides_app_level_disable_flags() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(false), + open_world_enabled: Some(false), + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: Some(AppToolsConfig { + tools: HashMap::from([( + "events/create".to_string(), + AppToolConfig { + enabled: Some(true), + approval_mode: None, + }, + )]), + }), + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/create", + None, + Some(&annotations(Some(true), Some(true))), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_tool_policy_default_tools_enabled_true_overrides_app_level_tool_hints() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(false), + open_world_enabled: Some(false), + default_tools_approval_mode: None, + default_tools_enabled: Some(true), + tools: None, + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/create", + None, + Some(&annotations(Some(true), Some(true))), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_tool_policy_default_tools_enabled_false_overrides_app_level_tool_hints() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(true), + open_world_enabled: Some(true), + default_tools_approval_mode: Some(AppToolApproval::Approve), + default_tools_enabled: Some(false), + tools: None, + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Approve, + } + ); + } + + #[test] + fn app_tool_policy_uses_default_tools_approval_mode() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: Some(AppToolsConfig { + tools: HashMap::new(), + }), + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Prompt, + } + ); + } + + #[test] + fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(false), + open_world_enabled: Some(false), + default_tools_approval_mode: Some(AppToolApproval::Auto), + default_tools_enabled: Some(false), + tools: Some(AppToolsConfig { + tools: HashMap::from([( + "events/create".to_string(), + AppToolConfig { + enabled: Some(true), + approval_mode: Some(AppToolApproval::Approve), + }, + )]), + }), + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "calendar_events/create", + Some("events/create"), + Some(&annotations(Some(true), Some(true))), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Approve, + } + ); + } + #[test] fn filter_disallowed_connectors_allows_non_disallowed_connectors() { let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]); @@ -528,7 +1006,7 @@ mod tests { ); assert_eq!( filtered, - vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c"),] + vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c")] ); } } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 5f7734118c..00327386b5 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -7,6 +7,8 @@ use crate::analytics_client::AppInvocation; use crate::analytics_client::build_track_events_context; use crate::codex::Session; use crate::codex::TurnContext; +use crate::config::types::AppToolApproval; +use crate::connectors; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; @@ -64,9 +66,51 @@ pub(crate) async fn handle_mcp_tool_call( arguments: arguments_value.clone(), }; - if let Some(decision) = - maybe_request_mcp_tool_approval(sess.as_ref(), turn_context, &call_id, &server, &tool_name) - .await + let metadata = lookup_mcp_tool_metadata(sess.as_ref(), &server, &tool_name).await; + let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME { + connectors::app_tool_policy( + &turn_context.config, + metadata + .as_ref() + .and_then(|metadata| metadata.connector_id.as_deref()), + &tool_name, + metadata + .as_ref() + .and_then(|metadata| metadata.tool_title.as_deref()), + metadata + .as_ref() + .and_then(|metadata| metadata.annotations.as_ref()), + ) + } else { + connectors::AppToolPolicy::default() + }; + + if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled { + let result = notify_mcp_tool_call_skip( + sess.as_ref(), + turn_context, + &call_id, + invocation, + "MCP tool call blocked by app configuration".to_string(), + ) + .await; + let status = if result.is_ok() { "ok" } else { "error" }; + turn_context + .otel_manager + .counter("codex.mcp.call", 1, &[("status", status)]); + return ResponseInputItem::McpToolCallOutput { call_id, result }; + } + + if let Some(decision) = maybe_request_mcp_tool_approval( + sess.as_ref(), + turn_context, + &call_id, + &server, + &tool_name, + metadata.as_ref(), + app_tool_policy.approval, + ) + .await { let result = match decision { McpToolApprovalDecision::Accept | McpToolApprovalDecision::AcceptAndRemember => { @@ -267,7 +311,7 @@ enum McpToolApprovalDecision { } struct McpToolApprovalMetadata { - annotations: ToolAnnotations, + annotations: Option, connector_id: Option, connector_name: Option, tool_title: Option, @@ -292,21 +336,39 @@ async fn maybe_request_mcp_tool_approval( call_id: &str, server: &str, tool_name: &str, + metadata: Option<&McpToolApprovalMetadata>, + approval_mode: AppToolApproval, ) -> Option { - if is_full_access_mode(turn_context) { + if approval_mode == AppToolApproval::Approve { return None; } + let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); + if approval_mode == AppToolApproval::Auto { + if is_full_access_mode(turn_context) { + return None; + } + if !annotations.is_some_and(requires_mcp_tool_approval) { + return None; + } + } - let metadata = lookup_mcp_tool_metadata(sess, server, tool_name).await?; - if !requires_mcp_tool_approval(&metadata.annotations) { - return None; - } - let approval_key = McpToolApprovalKey { - server: server.to_string(), - connector_id: metadata.connector_id.clone(), - tool_name: tool_name.to_string(), + let approval_key = if approval_mode == AppToolApproval::Auto { + let connector_id = metadata.and_then(|metadata| metadata.connector_id.clone()); + if server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() { + None + } else { + Some(McpToolApprovalKey { + server: server.to_string(), + connector_id, + tool_name: tool_name.to_string(), + }) + } + } else { + None }; - if mcp_tool_approval_is_remembered(sess, &approval_key).await { + if let Some(key) = approval_key.as_ref() + && mcp_tool_approval_is_remembered(sess, key).await + { return Some(McpToolApprovalDecision::Accept); } @@ -315,10 +377,10 @@ async fn maybe_request_mcp_tool_approval( question_id.clone(), server, tool_name, - metadata.tool_title.as_deref(), - metadata.connector_name.as_deref(), - &metadata.annotations, - true, + metadata.and_then(|metadata| metadata.tool_title.as_deref()), + metadata.and_then(|metadata| metadata.connector_name.as_deref()), + annotations, + approval_key.is_some(), ); let args = RequestUserInputArgs { questions: vec![question], @@ -326,9 +388,14 @@ async fn maybe_request_mcp_tool_approval( let response = sess .request_user_input(turn_context, call_id.to_string(), args) .await; - let decision = parse_mcp_tool_approval_response(response, &question_id); - if matches!(decision, McpToolApprovalDecision::AcceptAndRemember) { - remember_mcp_tool_approval(sess, approval_key).await; + let decision = normalize_approval_decision_for_mode( + parse_mcp_tool_approval_response(response, &question_id), + approval_mode, + ); + if matches!(decision, McpToolApprovalDecision::AcceptAndRemember) + && let Some(key) = approval_key + { + remember_mcp_tool_approval(sess, key).await; } Some(decision) } @@ -356,15 +423,12 @@ async fn lookup_mcp_tool_metadata( tools.into_values().find_map(|tool_info| { if tool_info.server_name == server && tool_info.tool_name == tool_name { - tool_info - .tool - .annotations - .map(|annotations| McpToolApprovalMetadata { - annotations, - connector_id: tool_info.connector_id, - connector_name: tool_info.connector_name, - tool_title: tool_info.tool.title, - }) + Some(McpToolApprovalMetadata { + annotations: tool_info.tool.annotations, + connector_id: tool_info.connector_id, + connector_name: tool_info.connector_name, + tool_title: tool_info.tool.title, + }) } else { None } @@ -402,11 +466,12 @@ fn build_mcp_tool_approval_question( tool_name: &str, tool_title: Option<&str>, connector_name: Option<&str>, - annotations: &ToolAnnotations, + annotations: Option<&ToolAnnotations>, allow_remember_option: bool, ) -> RequestUserInputQuestion { - let destructive = annotations.destructive_hint == Some(true); - let open_world = annotations.open_world_hint == Some(true); + let destructive = + annotations.and_then(|annotations| annotations.destructive_hint) == Some(true); + let open_world = annotations.and_then(|annotations| annotations.open_world_hint) == Some(true); let reason = match (destructive, open_world) { (true, true) => "may modify data and access external systems", (true, false) => "may modify or delete data", @@ -493,6 +558,19 @@ fn parse_mcp_tool_approval_response( } } +fn normalize_approval_decision_for_mode( + decision: McpToolApprovalDecision, + approval_mode: AppToolApproval, +) -> McpToolApprovalDecision { + if approval_mode == AppToolApproval::Prompt + && decision == McpToolApprovalDecision::AcceptAndRemember + { + McpToolApprovalDecision::Accept + } else { + decision + } +} + async fn mcp_tool_approval_is_remembered(sess: &Session, key: &McpToolApprovalKey) -> bool { let store = sess.services.tool_approvals.lock().await; matches!(store.get(key), Some(ReviewDecision::ApprovedForSession)) @@ -568,6 +646,17 @@ mod tests { assert_eq!(requires_mcp_tool_approval(&annotations), false); } + #[test] + fn prompt_mode_does_not_allow_session_remember() { + assert_eq!( + normalize_approval_decision_for_mode( + McpToolApprovalDecision::AcceptAndRemember, + AppToolApproval::Prompt, + ), + McpToolApprovalDecision::Accept + ); + } + #[test] fn custom_mcp_tool_question_mentions_server_name() { let question = build_mcp_tool_approval_question( @@ -576,7 +665,7 @@ mod tests { "run_action", Some("Run Action"), None, - &annotations(Some(false), Some(true), None), + Some(&annotations(Some(false), Some(true), None)), true, ); @@ -603,7 +692,7 @@ mod tests { "run_action", Some("Run Action"), None, - &annotations(Some(false), Some(true), None), + Some(&annotations(Some(false), Some(true), None)), true, ); diff --git a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs index c5e267cb49..a4878b73a6 100644 --- a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs +++ b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs @@ -124,6 +124,7 @@ impl ToolHandler for SearchToolBm25Handler { &turn.config, ); let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors); + let mcp_tools = connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config); let mut entries: Vec = mcp_tools .into_iter() From d3cf8bd0fa6880b9a14ad8f9afb5a176adbc7291 Mon Sep 17 00:00:00 2001 From: colby-oai <228809017+colby-oai@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:12:16 -0500 Subject: [PATCH 004/282] fix(core): require approval for destructive MCP tool calls (#12353) Summary - ensure destructive tool annotations short-circuit to require approval - simplify approval logic to only require read/write + open-world when destructive is false - update the unit test to cover the new destructive behavior Testing - Not run (not requested) --- codex-rs/core/src/mcp_tool_call.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 00327386b5..f194ee79a6 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -582,8 +582,11 @@ async fn remember_mcp_tool_approval(sess: &Session, key: McpToolApprovalKey) { } fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool { - annotations.read_only_hint == Some(false) - && (annotations.destructive_hint == Some(true) || annotations.open_world_hint == Some(true)) + if annotations.destructive_hint == Some(true) { + return true; + } + + annotations.read_only_hint == Some(false) && annotations.open_world_hint == Some(true) } async fn notify_mcp_tool_call_skip( @@ -641,9 +644,9 @@ mod tests { } #[test] - fn approval_not_required_when_read_only_true() { + fn approval_required_when_destructive_even_if_read_only_true() { let annotations = annotations(Some(true), Some(true), Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), false); + assert_eq!(requires_mcp_tool_approval(&annotations), true); } #[test] From 6b1091fc92b7bac7cd25bc4772081eff7ad8f9ac Mon Sep 17 00:00:00 2001 From: Max Johnson <162359438+maxj-oai@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:35:16 -0800 Subject: [PATCH 005/282] app-server: harden disconnect cleanup paths (#12218) Hardens codex-rs/app-server connection lifecycle and outbound routing for websocket clients. Fixes some FUD I was having - Added per-connection disconnect signaling (CancellationToken) for websocket transports. - Split websocket handling into independent inbound/outbound tasks coordinated by cancellation. - Changed outbound routing so websocket connections use non-blocking try_send; slow/full websocket writers are disconnected instead of stalling broadcast delivery. - Kept stdio behavior blocking-on-send (no forced disconnect) so local stdio clients are not dropped when queues are temporarily full. - Simplified outbound router flow by removing deferred pending_closed_connections handling. - Added guards to drop incoming response/notification/error messages from unknown connections. - Fixed listener teardown race in thread listener tasks using a listener_generation check so stale tasks do not clear newer listeners. Fixes https://linear.app/openai/issue/CODEX-4966/multiclient-handle-slow-notification-consumers ## Tests Added/updated transport tests covering: - broadcast does not block on a slow/full websocket connection - stdio connection waits instead of disconnecting on full queue I (maxj) have tested manually and will retest before landing --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/codex_message_processor.rs | 7 +- codex-rs/app-server/src/lib.rs | 100 ++--- codex-rs/app-server/src/thread_state.rs | 6 +- codex-rs/app-server/src/transport.rs | 344 ++++++++++++++++-- 6 files changed, 372 insertions(+), 87 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ccdfddb3ac..7818b253cf 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1327,6 +1327,7 @@ dependencies = [ "time", "tokio", "tokio-tungstenite", + "tokio-util", "toml 0.9.11+spec-1.1.0", "tracing", "tracing-subscriber", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 302a65d2f6..7b90affe69 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -48,6 +48,7 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } +tokio-util = { workspace = true } tokio-tungstenite = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 24c89edaf3..29d8acdbc1 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5811,7 +5811,7 @@ impl CodexMessageProcessor { api_version: ApiVersion, ) { let (cancel_tx, mut cancel_rx) = oneshot::channel(); - let mut listener_command_rx = { + let (mut listener_command_rx, listener_generation) = { let mut thread_state = thread_state.lock().await; if thread_state.listener_matches(&conversation) { return; @@ -5927,6 +5927,11 @@ impl CodexMessageProcessor { } } } + + let mut thread_state = thread_state.lock().await; + if thread_state.listener_generation == listener_generation { + thread_state.clear_listener(); + } }); } async fn git_diff_to_origin(&self, request_id: ConnectionRequestId, cwd: PathBuf) { diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 6ff802c2a9..1bdbf97a13 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -10,7 +10,6 @@ use codex_core::config_loader::LoaderOverrides; use codex_utils_cli::CliConfigOverrides; use std::collections::HashMap; use std::collections::HashSet; -use std::collections::VecDeque; use std::io::ErrorKind; use std::io::Result as IoResult; use std::path::PathBuf; @@ -42,6 +41,7 @@ use codex_core::config_loader::TextRange as CoreTextRange; use codex_feedback::CodexFeedback; use tokio::sync::mpsc; use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; use toml::Value as TomlValue; use tracing::error; use tracing::info; @@ -92,6 +92,7 @@ enum OutboundControlEvent { Opened { connection_id: ConnectionId, writer: mpsc::Sender, + disconnect_sender: Option, initialized: Arc, opted_out_notification_methods: Arc>>, }, @@ -403,61 +404,43 @@ pub async fn run_main_with_transport( } } - let transport_event_tx_for_outbound = transport_event_tx.clone(); let outbound_handle = tokio::spawn(async move { let mut outbound_connections = HashMap::::new(); - let mut pending_closed_connections = VecDeque::::new(); loop { tokio::select! { - biased; - event = outbound_control_rx.recv() => { - let Some(event) = event else { - break; - }; - match event { - OutboundControlEvent::Opened { - connection_id, - writer, - initialized, - opted_out_notification_methods, - } => { - outbound_connections.insert( + biased; + event = outbound_control_rx.recv() => { + let Some(event) = event else { + break; + }; + match event { + OutboundControlEvent::Opened { connection_id, - OutboundConnectionState::new( - writer, - initialized, - opted_out_notification_methods, - ), - ); - } - OutboundControlEvent::Closed { connection_id } => { - outbound_connections.remove(&connection_id); + writer, + disconnect_sender, + initialized, + opted_out_notification_methods, + } => { + outbound_connections.insert( + connection_id, + OutboundConnectionState::new( + writer, + initialized, + opted_out_notification_methods, + disconnect_sender, + ), + ); + } + OutboundControlEvent::Closed { connection_id } => { + outbound_connections.remove(&connection_id); + } } } - } - envelope = outgoing_rx.recv() => { + envelope = outgoing_rx.recv() => { let Some(envelope) = envelope else { break; }; - let disconnected_connections = - route_outgoing_envelope(&mut outbound_connections, envelope).await; - pending_closed_connections.extend(disconnected_connections); - } - } - - while let Some(connection_id) = pending_closed_connections.front().copied() { - match transport_event_tx_for_outbound - .try_send(TransportEvent::ConnectionClosed { connection_id }) - { - Ok(()) => { - pending_closed_connections.pop_front(); - } - Err(mpsc::error::TrySendError::Full(_)) => { - break; - } - Err(mpsc::error::TrySendError::Closed(_)) => { - return; - } + route_outgoing_envelope(&mut outbound_connections, envelope).await; } } } @@ -491,7 +474,11 @@ pub async fn run_main_with_transport( break; }; match event { - TransportEvent::ConnectionOpened { connection_id, writer } => { + TransportEvent::ConnectionOpened { + connection_id, + writer, + disconnect_sender, + } => { let outbound_initialized = Arc::new(AtomicBool::new(false)); let outbound_opted_out_notification_methods = Arc::new(RwLock::new(HashSet::new())); @@ -499,6 +486,7 @@ pub async fn run_main_with_transport( .send(OutboundControlEvent::Opened { connection_id, writer, + disconnect_sender, initialized: Arc::clone(&outbound_initialized), opted_out_notification_methods: Arc::clone( &outbound_opted_out_notification_methods, @@ -518,6 +506,9 @@ pub async fn run_main_with_transport( ); } TransportEvent::ConnectionClosed { connection_id } => { + if connections.remove(&connection_id).is_none() { + continue; + } if outbound_control_tx .send(OutboundControlEvent::Closed { connection_id }) .await @@ -526,7 +517,6 @@ pub async fn run_main_with_transport( break; } processor.connection_closed(connection_id).await; - connections.remove(&connection_id); if shutdown_when_no_connections && connections.is_empty() { break; } @@ -535,7 +525,7 @@ pub async fn run_main_with_transport( match message { JSONRPCMessage::Request(request) => { let Some(connection_state) = connections.get_mut(&connection_id) else { - warn!("dropping request from unknown connection: {:?}", connection_id); + warn!("dropping request from unknown connection: {connection_id:?}"); continue; }; let was_initialized = connection_state.session.initialized; @@ -565,12 +555,24 @@ pub async fn run_main_with_transport( } } JSONRPCMessage::Response(response) => { + if !connections.contains_key(&connection_id) { + warn!("dropping response from unknown connection: {connection_id:?}"); + continue; + } processor.process_response(response).await; } JSONRPCMessage::Notification(notification) => { + if !connections.contains_key(&connection_id) { + warn!("dropping notification from unknown connection: {connection_id:?}"); + continue; + } processor.process_notification(notification).await; } JSONRPCMessage::Error(err) => { + if !connections.contains_key(&connection_id) { + warn!("dropping error from unknown connection: {connection_id:?}"); + continue; + } processor.process_error(err).await; } } diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 264c9a39d6..a9d35e0b7c 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -47,6 +47,7 @@ pub(crate) struct ThreadState { pub(crate) turn_summary: TurnSummary, pub(crate) cancel_tx: Option>, pub(crate) experimental_raw_events: bool, + pub(crate) listener_generation: u64, listener_command_tx: Option>, current_turn_history: ThreadHistoryBuilder, listener_thread: Option>, @@ -65,14 +66,15 @@ impl ThreadState { &mut self, cancel_tx: oneshot::Sender<()>, conversation: &Arc, - ) -> mpsc::UnboundedReceiver { + ) -> (mpsc::UnboundedReceiver, u64) { if let Some(previous) = self.cancel_tx.replace(cancel_tx) { let _ = previous.send(()); } + self.listener_generation = self.listener_generation.wrapping_add(1); let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel(); self.listener_command_tx = Some(listener_command_tx); self.listener_thread = Some(Arc::downgrade(conversation)); - listener_command_rx + (listener_command_rx, self.listener_generation) } pub(crate) fn clear_listener(&mut self) { diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 558d786137..80768620bb 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -32,6 +32,7 @@ use tokio::sync::mpsc; use tokio::task::JoinHandle; use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message as WebSocketMessage; +use tokio_util::sync::CancellationToken; use tracing::debug; use tracing::error; use tracing::info; @@ -135,6 +136,7 @@ pub(crate) enum TransportEvent { ConnectionOpened { connection_id: ConnectionId, writer: mpsc::Sender, + disconnect_sender: Option, }, ConnectionClosed { connection_id: ConnectionId, @@ -168,6 +170,7 @@ pub(crate) struct OutboundConnectionState { pub(crate) initialized: Arc, pub(crate) opted_out_notification_methods: Arc>>, pub(crate) writer: mpsc::Sender, + disconnect_sender: Option, } impl OutboundConnectionState { @@ -175,11 +178,23 @@ impl OutboundConnectionState { writer: mpsc::Sender, initialized: Arc, opted_out_notification_methods: Arc>>, + disconnect_sender: Option, ) -> Self { Self { initialized, opted_out_notification_methods, writer, + disconnect_sender, + } + } + + fn can_disconnect(&self) -> bool { + self.disconnect_sender.is_some() + } + + fn request_disconnect(&self) { + if let Some(disconnect_sender) = &self.disconnect_sender { + disconnect_sender.cancel(); } } } @@ -195,6 +210,7 @@ pub(crate) async fn start_stdio_connection( .send(TransportEvent::ConnectionOpened { connection_id, writer: writer_tx, + disconnect_sender: None, }) .await .map_err(|_| std::io::Error::new(ErrorKind::BrokenPipe, "processor unavailable"))?; @@ -299,12 +315,14 @@ async fn run_websocket_connection( } }; - let (writer_tx, mut writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let (writer_tx, writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); let writer_tx_for_reader = writer_tx.clone(); + let disconnect_token = CancellationToken::new(); if transport_event_tx .send(TransportEvent::ConnectionOpened { connection_id, writer: writer_tx, + disconnect_sender: Some(disconnect_token.clone()), }) .await .is_err() @@ -312,9 +330,62 @@ async fn run_websocket_connection( return; } - let (mut websocket_writer, mut websocket_reader) = websocket_stream.split(); + let (websocket_writer, websocket_reader) = websocket_stream.split(); + let (writer_control_tx, writer_control_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let mut outbound_task = tokio::spawn(run_websocket_outbound_loop( + websocket_writer, + writer_rx, + writer_control_rx, + disconnect_token.clone(), + )); + let mut inbound_task = tokio::spawn(run_websocket_inbound_loop( + websocket_reader, + transport_event_tx.clone(), + writer_tx_for_reader, + writer_control_tx, + connection_id, + disconnect_token.clone(), + )); + + tokio::select! { + _ = &mut outbound_task => { + disconnect_token.cancel(); + inbound_task.abort(); + } + _ = &mut inbound_task => { + disconnect_token.cancel(); + outbound_task.abort(); + } + } + + let _ = transport_event_tx + .send(TransportEvent::ConnectionClosed { connection_id }) + .await; +} + +async fn run_websocket_outbound_loop( + mut websocket_writer: futures::stream::SplitSink< + tokio_tungstenite::WebSocketStream, + WebSocketMessage, + >, + mut writer_rx: mpsc::Receiver, + mut writer_control_rx: mpsc::Receiver, + disconnect_token: CancellationToken, +) { loop { tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + message = writer_control_rx.recv() => { + let Some(message) = message else { + break; + }; + if websocket_writer.send(message).await.is_err() { + break; + } + } outgoing_message = writer_rx.recv() => { let Some(outgoing_message) = outgoing_message else { break; @@ -326,6 +397,25 @@ async fn run_websocket_connection( break; } } + } + } +} + +async fn run_websocket_inbound_loop( + mut websocket_reader: futures::stream::SplitStream< + tokio_tungstenite::WebSocketStream, + >, + transport_event_tx: mpsc::Sender, + writer_tx_for_reader: mpsc::Sender, + writer_control_tx: mpsc::Sender, + connection_id: ConnectionId, + disconnect_token: CancellationToken, +) { + loop { + tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } incoming_message = websocket_reader.next() => { match incoming_message { Some(Ok(WebSocketMessage::Text(text))) => { @@ -341,8 +431,13 @@ async fn run_websocket_connection( } } Some(Ok(WebSocketMessage::Ping(payload))) => { - if websocket_writer.send(WebSocketMessage::Pong(payload)).await.is_err() { - break; + match writer_control_tx.try_send(WebSocketMessage::Pong(payload)) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => break, + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + warn!("websocket control queue full while replying to ping; closing connection"); + break; + } } } Some(Ok(WebSocketMessage::Pong(_))) => {} @@ -359,10 +454,6 @@ async fn run_websocket_connection( } } } - - let _ = transport_event_tx - .send(TransportEvent::ConnectionClosed { connection_id }) - .await; } async fn forward_incoming_message( @@ -461,30 +552,61 @@ fn should_skip_notification_for_connection( } } +fn disconnect_connection( + connections: &mut HashMap, + connection_id: ConnectionId, +) -> bool { + if let Some(connection_state) = connections.remove(&connection_id) { + connection_state.request_disconnect(); + return true; + } + false +} + +async fn send_message_to_connection( + connections: &mut HashMap, + connection_id: ConnectionId, + message: OutgoingMessage, +) -> bool { + let Some(connection_state) = connections.get(&connection_id) else { + warn!("dropping message for disconnected connection: {connection_id:?}"); + return false; + }; + if should_skip_notification_for_connection(connection_state, &message) { + return false; + } + + let writer = connection_state.writer.clone(); + if connection_state.can_disconnect() { + match writer.try_send(message) { + Ok(()) => false, + Err(mpsc::error::TrySendError::Full(_)) => { + warn!( + "disconnecting slow connection after outbound queue filled: {connection_id:?}" + ); + disconnect_connection(connections, connection_id) + } + Err(mpsc::error::TrySendError::Closed(_)) => { + disconnect_connection(connections, connection_id) + } + } + } else if writer.send(message).await.is_err() { + disconnect_connection(connections, connection_id) + } else { + false + } +} + pub(crate) async fn route_outgoing_envelope( connections: &mut HashMap, envelope: OutgoingEnvelope, -) -> Vec { - let mut disconnected = Vec::new(); +) { match envelope { OutgoingEnvelope::ToConnection { connection_id, message, } => { - let Some(connection_state) = connections.get(&connection_id) else { - warn!( - "dropping message for disconnected connection: {:?}", - connection_id - ); - return disconnected; - }; - if should_skip_notification_for_connection(connection_state, &message) { - return disconnected; - } - if connection_state.writer.send(message).await.is_err() { - connections.remove(&connection_id); - disconnected.push(connection_id); - } + let _ = send_message_to_connection(connections, connection_id, message).await; } OutgoingEnvelope::Broadcast { message } => { let target_connections: Vec = connections @@ -501,17 +623,11 @@ pub(crate) async fn route_outgoing_envelope( .collect(); for connection_id in target_connections { - let Some(connection_state) = connections.get(&connection_id) else { - continue; - }; - if connection_state.writer.send(message.clone()).await.is_err() { - connections.remove(&connection_id); - disconnected.push(connection_id); - } + let _ = + send_message_to_connection(connections, connection_id, message.clone()).await; } } } - disconnected } #[cfg(test)] @@ -520,6 +636,8 @@ mod tests { use crate::error_code::OVERLOADED_ERROR_CODE; use pretty_assertions::assert_eq; use serde_json::json; + use tokio::time::Duration; + use tokio::time::timeout; #[test] fn app_server_transport_parses_stdio_listen_url() { @@ -754,10 +872,15 @@ mod tests { let mut connections = HashMap::new(); connections.insert( connection_id, - OutboundConnectionState::new(writer_tx, initialized, opted_out_notification_methods), + OutboundConnectionState::new( + writer_tx, + initialized, + opted_out_notification_methods, + None, + ), ); - let disconnected = route_outgoing_envelope( + route_outgoing_envelope( &mut connections, OutgoingEnvelope::ToConnection { connection_id, @@ -771,10 +894,161 @@ mod tests { ) .await; - assert_eq!(disconnected, Vec::::new()); assert!( writer_rx.try_recv().is_err(), "opted-out notification should be dropped" ); } + + #[tokio::test] + async fn broadcast_does_not_block_on_slow_connection() { + let fast_connection_id = ConnectionId(1); + let slow_connection_id = ConnectionId(2); + + let (fast_writer_tx, mut fast_writer_rx) = mpsc::channel(1); + let (slow_writer_tx, mut slow_writer_rx) = mpsc::channel(1); + let fast_disconnect_token = CancellationToken::new(); + let slow_disconnect_token = CancellationToken::new(); + + let mut connections = HashMap::new(); + connections.insert( + fast_connection_id, + OutboundConnectionState::new( + fast_writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + Some(fast_disconnect_token.clone()), + ), + ); + connections.insert( + slow_connection_id, + OutboundConnectionState::new( + slow_writer_tx.clone(), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + Some(slow_disconnect_token.clone()), + ), + ); + + let queued_message = + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method: "codex/event/already-buffered".to_string(), + params: None, + }); + slow_writer_tx + .try_send(queued_message) + .expect("channel should have room"); + + let broadcast_message = + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method: "codex/event/test".to_string(), + params: None, + }); + timeout( + Duration::from_millis(100), + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::Broadcast { + message: broadcast_message, + }, + ), + ) + .await + .expect("broadcast should not block on a full writer"); + assert!(!connections.contains_key(&slow_connection_id)); + assert!(slow_disconnect_token.is_cancelled()); + assert!(!fast_disconnect_token.is_cancelled()); + let fast_message = fast_writer_rx + .try_recv() + .expect("fast connection should receive broadcast"); + assert!(matches!( + fast_message, + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method, + params: None, + }) if method == "codex/event/test" + )); + + let slow_message = slow_writer_rx + .try_recv() + .expect("slow connection should retain its original buffered message"); + assert!(matches!( + slow_message, + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method, + params: None, + }) if method == "codex/event/already-buffered" + )); + } + + #[tokio::test] + async fn to_connection_stdio_waits_instead_of_disconnecting_when_writer_queue_is_full() { + let connection_id = ConnectionId(3); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + writer_tx + .send(OutgoingMessage::Notification( + crate::outgoing_message::OutgoingNotification { + method: "queued".to_string(), + params: None, + }, + )) + .await + .expect("channel should accept the first queued message"); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + None, + ), + ); + + let route_task = tokio::spawn(async move { + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::Notification( + crate::outgoing_message::OutgoingNotification { + method: "second".to_string(), + params: None, + }, + ), + }, + ) + .await + }); + + let first = timeout(Duration::from_millis(100), writer_rx.recv()) + .await + .expect("first queued message should be readable") + .expect("first queued message should exist"); + let second = timeout(Duration::from_millis(100), writer_rx.recv()) + .await + .expect("second message should eventually be delivered") + .expect("second message should exist"); + + timeout(Duration::from_millis(100), route_task) + .await + .expect("routing should finish after writer drains") + .expect("routing task should succeed"); + + assert!(matches!( + first, + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method, + params: None, + }) if method == "queued" + )); + assert!(matches!( + second, + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method, + params: None, + }) if method == "second" + )); + } } From 60c2b7beca5929346311388d50b1bdeb59673077 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 20 Feb 2026 12:50:12 -0800 Subject: [PATCH 006/282] core tests: use hermetic mock server in review suite (#12291) ## Summary - switch the review test SSE mock helper to use the shared hermetic mock server setup - ensure review tests always have a default `/v1/models` stub during Codex session bootstrap - remove the race that caused intermittent `/v1/models` connection failures and flaky ETag refresh assertions ## Testing - `just fmt` - `cargo test -p codex-core --test all refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch` - `cargo test -p codex-core --test all review_uses_custom_review_model_from_config` - repeated both targeted tests 5x in a loop - `cargo clippy -p codex-core --tests -- -D warnings` --- codex-rs/core/tests/suite/review.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 1c9c3adf7b..113776b81f 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -20,6 +20,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::load_sse_fixture_with_id_from_str; use core_test_support::responses::ResponseMock; use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -872,7 +873,7 @@ async fn start_responses_server_with_sse( sse_raw: &str, expected_requests: usize, ) -> (MockServer, ResponseMock) { - let server = MockServer::start().await; + let server = start_mock_server().await; let sse = load_sse_fixture_with_id_from_str(sse_raw, &Uuid::new_v4().to_string()); let responses = vec![sse; expected_requests]; let request_log = mount_sse_sequence(&server, responses).await; From 354e7fedd25576d51220ee277bab5d4ca4f4f716 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 20 Feb 2026 14:05:55 -0800 Subject: [PATCH 007/282] [apps] Enforce simple logo url format. (#12374) - [x] Enforce simple logo url format when loading apps directory to save bandwidth. --- codex-rs/app-server/tests/suite/v2/app_list.rs | 13 ++++++++++--- codex-rs/chatgpt/src/connectors.rs | 8 +++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 3a2064d4cb..a9bc14e932 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -15,6 +15,7 @@ use axum::Router; use axum::extract::State; use axum::http::HeaderMap; use axum::http::StatusCode; +use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppBranding; @@ -1215,6 +1216,7 @@ async fn start_apps_server_with_delays_and_control( async fn list_directory_connectors( State(state): State>, headers: HeaderMap, + uri: Uri, ) -> Result { if state.directory_delay > Duration::ZERO { tokio::time::sleep(state.directory_delay).await; @@ -1228,16 +1230,21 @@ async fn list_directory_connectors( .get("chatgpt-account-id") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value == state.expected_account_id); + let external_logos_ok = uri + .query() + .is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true")); - if bearer_ok && account_ok { + if !bearer_ok || !account_ok { + Err(StatusCode::UNAUTHORIZED) + } else if !external_logos_ok { + Err(StatusCode::BAD_REQUEST) + } else { let response = state .response .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); Ok(Json(response)) - } else { - Err(StatusCode::UNAUTHORIZED) } } diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 27f2f1de94..a3470ff1b9 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -220,9 +220,11 @@ async fn list_directory_connectors(config: &Config) -> anyhow::Result { let encoded_token = urlencoding::encode(token); - format!("/connectors/directory/list?tier=categorized&token={encoded_token}") + format!( + "/connectors/directory/list?tier=categorized&token={encoded_token}&external_logos=true" + ) } - None => "/connectors/directory/list?tier=categorized".to_string(), + None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(), }; let response: DirectoryListResponse = chatgpt_get_request_with_timeout(config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT)) @@ -247,7 +249,7 @@ async fn list_directory_connectors(config: &Config) -> anyhow::Result anyhow::Result> { let response: anyhow::Result = chatgpt_get_request_with_timeout( config, - "/connectors/directory/list_workspace".to_string(), + "/connectors/directory/list_workspace?external_logos=true".to_string(), Some(DIRECTORY_CONNECTORS_TIMEOUT), ) .await; From 9176f09cb8300e90af322ed3850219ec9ab8da01 Mon Sep 17 00:00:00 2001 From: derekf-oai Date: Fri, 20 Feb 2026 14:12:13 -0800 Subject: [PATCH 008/282] docs: use --locked when installing cargo-nextest (#12377) ## What Updates the optional `cargo-nextest` install command in `docs/install.md`: - `cargo install cargo-nextest` -> `cargo install --locked cargo-nextest` ## Why The current docs command can fail during source install because recent `cargo-nextest` releases intentionally require `--locked`. Repro (macOS, but likely not platform-specific): - `cargo install cargo-nextest` - Fails with a compile error from `locked-tripwire` indicating: - `Nextest does not support being installed without --locked` - suggests `cargo install --locked cargo-nextest` Using the locked command succeeds: - `cargo install --locked cargo-nextest` ## How Single-line docs change in `docs/install.md` to match current `cargo-nextest` install requirements. ## Validation - Reproduced failure locally using a temporary `CARGO_HOME` directory (clean Cargo home) - Example command used: `CARGO_HOME=/tmp/cargo-home-test cargo install cargo-nextest` - Confirmed success with `cargo install --locked cargo-nextest` --- docs/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install.md b/docs/install.md index ef14f5d840..16892c82fe 100644 --- a/docs/install.md +++ b/docs/install.md @@ -27,7 +27,7 @@ rustup component add clippy # Install helper tools used by the workspace justfile: cargo install just # Optional: install nextest for the `just test` helper -cargo install cargo-nextest +cargo install --locked cargo-nextest # Build Codex. cargo build From 1bb7989b20b25ff7e4017fc0f9d9e2de5ee4834b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 20 Feb 2026 14:26:14 -0800 Subject: [PATCH 009/282] Add ability to attach extra files to feedback (#12370) Allow clients to provide extra files. --- .../schema/json/ClientRequest.json | 9 +++++++++ .../json/codex_app_server_protocol.schemas.json | 9 +++++++++ .../schema/json/v2/FeedbackUploadParams.json | 9 +++++++++ .../typescript/v2/FeedbackUploadParams.ts | 2 +- codex-rs/app-server-protocol/src/protocol/v2.rs | 2 ++ codex-rs/app-server/README.md | 2 +- .../app-server/src/codex_message_processor.rs | 9 +++++++-- codex-rs/feedback/src/lib.rs | 17 ++++++++++++++--- codex-rs/tui/src/bottom_pane/feedback_view.rs | 12 ++++++------ 9 files changed, 58 insertions(+), 13 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9fc2a99141..76ba36735c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -507,6 +507,15 @@ "classification": { "type": "string" }, + "extraLogFiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "includeLogs": { "type": "boolean" }, 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 7a01b04b51..313c3e62b3 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 @@ -12549,6 +12549,15 @@ "classification": { "type": "string" }, + "extraLogFiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "includeLogs": { "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json index e4171b27f4..47b752b868 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json @@ -4,6 +4,15 @@ "classification": { "type": "string" }, + "extraLogFiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "includeLogs": { "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts index 3066e65406..ebbe3b32d4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, }; +export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 103b5f56a0..833e628214 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1506,6 +1506,8 @@ pub struct FeedbackUploadParams { #[ts(optional = nullable)] pub thread_id: Option, pub include_logs: bool, + #[ts(optional = nullable)] + pub extra_log_files: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 95ab4e43eb..8ee15688c6 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -150,7 +150,7 @@ Example with notification opt-out: - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. - `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); returns `{ started: true }` immediately and later emits `windowsSandbox/setupCompleted`. -- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id. +- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `config/read` — fetch the effective config on disk after resolving config layering. - `config/value/write` — write a single config key/value to the user's config.toml on disk. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 29d8acdbc1..65b7f1ecb7 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6098,6 +6098,7 @@ impl CodexMessageProcessor { reason, thread_id, include_logs, + extra_log_files, } = params; let conversation_id = match thread_id.as_deref() { @@ -6127,15 +6128,19 @@ impl CodexMessageProcessor { } else { None }; + let mut attachment_paths = validated_rollout_path.into_iter().collect::>(); + if let Some(extra_log_files) = extra_log_files { + attachment_paths.extend(extra_log_files); + } + let session_source = self.thread_manager.session_source(); let upload_result = tokio::task::spawn_blocking(move || { - let rollout_path_ref = validated_rollout_path.as_deref(); snapshot.upload_feedback( &classification, reason.as_deref(), include_logs, - rollout_path_ref, + &attachment_paths, Some(session_source), ) }) diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index adda7173a1..a4fe6cc185 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -224,7 +224,7 @@ impl CodexLogSnapshot { classification: &str, reason: Option<&str>, include_logs: bool, - rollout_path: Option<&std::path::Path>, + extra_log_files: &[PathBuf], session_source: Option, ) -> Result<()> { use std::collections::BTreeMap; @@ -317,11 +317,22 @@ impl CodexLogSnapshot { })); } - if let Some((path, data)) = rollout_path.and_then(|p| fs::read(p).ok().map(|d| (p, d))) { + for path in extra_log_files { + let data = match fs::read(path) { + Ok(data) => data, + Err(err) => { + tracing::warn!( + path = %path.display(), + error = %err, + "failed to read log attachment; skipping" + ); + continue; + } + }; let fname = path .file_name() .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "rollout.jsonl".to_string()); + .unwrap_or_else(|| "extra-log.log".to_string()); let content_type = "text/plain".to_string(); envelope.add_item(EnvelopeItem::Attachment(Attachment { buffer: data, diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 6cbfbdb9e5..eeca6aef18 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -87,7 +87,11 @@ impl FeedbackNoteView { } else { Some(note.as_str()) }; - let rollout_path_ref = self.rollout_path.as_deref(); + let log_file_paths = if self.include_logs { + self.rollout_path.iter().cloned().collect::>() + } else { + Vec::new() + }; let classification = feedback_classification(self.category); let mut thread_id = self.snapshot.thread_id.clone(); @@ -96,11 +100,7 @@ impl FeedbackNoteView { classification, reason_opt, self.include_logs, - if self.include_logs { - rollout_path_ref - } else { - None - }, + &log_file_paths, Some(SessionSource::Cli), ); From 64f3827d109c923759ca60c58751a0cd686c446a Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 20 Feb 2026 14:47:54 -0800 Subject: [PATCH 010/282] Move sanitizer into codex-secrets (#12306) ## Summary - move the sanitizer implementation into `codex-secrets` (`secrets/src/sanitizer.rs`) and re-export `redact_secrets` - switch `codex-core` to depend on/import `codex-secrets` for sanitizer usage - remove the old `utils/sanitizer` crate wiring and refresh lockfiles ## Testing - `just fmt` - `cargo test -p codex-secrets` - `cargo test -p codex-core --no-run` - `cargo clippy -p codex-secrets -p codex-core --all-targets --all-features -- -D warnings` - `just bazel-lock-update` - `just bazel-lock-check` ## Notes - not run: `cargo test --all-features` (full workspace suite) --- codex-rs/Cargo.lock | 10 ++-------- codex-rs/Cargo.toml | 2 -- codex-rs/core/Cargo.toml | 2 +- codex-rs/core/src/memories/phase1.rs | 2 +- codex-rs/secrets/BUILD.bazel | 6 ++++++ codex-rs/secrets/Cargo.toml | 1 + codex-rs/secrets/src/lib.rs | 2 ++ .../sanitizer/src/lib.rs => secrets/src/sanitizer.rs} | 0 codex-rs/utils/sanitizer/BUILD.bazel | 6 ------ codex-rs/utils/sanitizer/Cargo.toml | 11 ----------- 10 files changed, 13 insertions(+), 29 deletions(-) create mode 100644 codex-rs/secrets/BUILD.bazel rename codex-rs/{utils/sanitizer/src/lib.rs => secrets/src/sanitizer.rs} (100%) delete mode 100644 codex-rs/utils/sanitizer/BUILD.bazel delete mode 100644 codex-rs/utils/sanitizer/Cargo.toml diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7818b253cf..f05879a243 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1637,6 +1637,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-secrets", "codex-shell-command", "codex-state", "codex-utils-absolute-path", @@ -1644,7 +1645,6 @@ dependencies = [ "codex-utils-home-dir", "codex-utils-pty", "codex-utils-readiness", - "codex-utils-sanitizer", "codex-utils-string", "codex-windows-sandbox", "core-foundation 0.9.4", @@ -2151,6 +2151,7 @@ dependencies = [ "keyring", "pretty_assertions", "rand 0.9.2", + "regex", "schemars 0.8.22", "serde", "serde_json", @@ -2439,13 +2440,6 @@ dependencies = [ "pretty_assertions", ] -[[package]] -name = "codex-utils-sanitizer" -version = "0.0.0" -dependencies = [ - "regex", -] - [[package]] name = "codex-utils-sleep-inhibitor" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index e27b08b7f4..8ff0c456b8 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -53,7 +53,6 @@ members = [ "utils/cli", "utils/elapsed", "utils/sandbox-summary", - "utils/sanitizer", "utils/sleep-inhibitor", "utils/approval-presets", "utils/oss", @@ -131,7 +130,6 @@ 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-sanitizer = { path = "utils/sanitizer" } codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } codex-utils-string = { path = "utils/string" } codex-windows-sandbox = { path = "windows-sandbox-rs" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index fe7c2b5aa9..2b62d96254 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -48,7 +48,7 @@ codex-utils-absolute-path = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } -codex-utils-sanitizer = { workspace = true } +codex-secrets = { workspace = true } codex-utils-string = { workspace = true } codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } dirs = { workspace = true } diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs index b356f61b38..d2fb6d11f4 100644 --- a/codex-rs/core/src/memories/phase1.rs +++ b/codex-rs/core/src/memories/phase1.rs @@ -20,7 +20,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::TokenUsage; -use codex_utils_sanitizer::redact_secrets; +use codex_secrets::redact_secrets; use futures::StreamExt; use serde::Deserialize; use serde_json::Value; diff --git a/codex-rs/secrets/BUILD.bazel b/codex-rs/secrets/BUILD.bazel new file mode 100644 index 0000000000..1b69e7c7e0 --- /dev/null +++ b/codex-rs/secrets/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "secrets", + crate_name = "codex_secrets", +) diff --git a/codex-rs/secrets/Cargo.toml b/codex-rs/secrets/Cargo.toml index de45af50a1..eae8f9005e 100644 --- a/codex-rs/secrets/Cargo.toml +++ b/codex-rs/secrets/Cargo.toml @@ -13,6 +13,7 @@ anyhow = { workspace = true } base64 = { workspace = true } codex-keyring-store = { workspace = true } rand = { workspace = true } +regex = { workspace = true } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/secrets/src/lib.rs b/codex-rs/secrets/src/lib.rs index a45860d8b5..1ec925fd4f 100644 --- a/codex-rs/secrets/src/lib.rs +++ b/codex-rs/secrets/src/lib.rs @@ -13,8 +13,10 @@ use sha2::Digest; use sha2::Sha256; mod local; +mod sanitizer; pub use local::LocalSecretsBackend; +pub use sanitizer::redact_secrets; const KEYRING_SERVICE: &str = "codex"; diff --git a/codex-rs/utils/sanitizer/src/lib.rs b/codex-rs/secrets/src/sanitizer.rs similarity index 100% rename from codex-rs/utils/sanitizer/src/lib.rs rename to codex-rs/secrets/src/sanitizer.rs diff --git a/codex-rs/utils/sanitizer/BUILD.bazel b/codex-rs/utils/sanitizer/BUILD.bazel deleted file mode 100644 index 3cc7465bd7..0000000000 --- a/codex-rs/utils/sanitizer/BUILD.bazel +++ /dev/null @@ -1,6 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "sanitizer", - crate_name = "codex_utils_sanitizer", -) diff --git a/codex-rs/utils/sanitizer/Cargo.toml b/codex-rs/utils/sanitizer/Cargo.toml deleted file mode 100644 index 6f96cc891c..0000000000 --- a/codex-rs/utils/sanitizer/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "codex-utils-sanitizer" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -regex = "1.12.3" - -[lints] -workspace = true From 65b9fe8f30c5889afc433181c3fb25a315633d79 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Fri, 20 Feb 2026 15:04:36 -0800 Subject: [PATCH 011/282] clarify model_catalog_json only applied on startup (#12379) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/core/config.schema.json | 2 +- codex-rs/core/src/config/mod.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index e73a71bb30..4de4da6e48 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1807,7 +1807,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Optional path to a JSON file containing a complete model catalog. When set, this replaces the bundled catalog for this process." + "description": "Optional path to a JSON model catalog (applied on startup only). Per-thread `config` overrides are accepted but do not reapply this (no-ops)." }, "model_context_window": { "description": "Size of the context window for the model, in tokens.", diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3b8e0382e8..76ba2fcb2d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1109,8 +1109,8 @@ pub struct ConfigToml { /// Override to force-enable reasoning summaries for the configured model. pub model_supports_reasoning_summaries: Option, - /// Optional path to a JSON file containing a complete model catalog. - /// When set, this replaces the bundled catalog for this process. + /// Optional path to a JSON model catalog (applied on startup only). + /// Per-thread `config` overrides are accepted but do not reapply this (no-ops). pub model_catalog_json: Option, /// Optionally specify a personality for the model From 021e39b3031de7661245eff61ed9bbe11ffca531 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Fri, 20 Feb 2026 15:22:10 -0800 Subject: [PATCH 012/282] Show model/reasoning hint when switching modes (#12307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - show an info message when switching collaboration modes changes the effective model or reasoning - include the target mode in the message (for example `... for Plan mode.`) - add TUI tests for model-change and reasoning-only change notifications on mode switch Screenshot 2026-02-20 at 2 01 40 PM --- codex-rs/tui/src/chatwidget.rs | 27 +++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 67 +++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7dcab8010a..ca5aa7c620 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6343,9 +6343,36 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return; } + let previous_mode = self.active_mode_kind(); + let previous_model = self.current_model().to_string(); + let previous_effort = self.effective_reasoning_effort(); self.active_collaboration_mask = Some(mask); self.update_collaboration_mode_indicator(); self.refresh_model_display(); + let next_mode = self.active_mode_kind(); + let next_model = self.current_model(); + let next_effort = self.effective_reasoning_effort(); + if previous_mode != next_mode + && (previous_model != next_model || previous_effort != next_effort) + { + let mut message = format!("Model changed to {next_model}"); + if !next_model.starts_with("codex-auto-") { + let reasoning_label = match next_effort { + Some(ReasoningEffortConfig::Minimal) => "minimal", + Some(ReasoningEffortConfig::Low) => "low", + Some(ReasoningEffortConfig::Medium) => "medium", + Some(ReasoningEffortConfig::High) => "high", + Some(ReasoningEffortConfig::XHigh) => "xhigh", + None | Some(ReasoningEffortConfig::None) => "default", + }; + message.push(' '); + message.push_str(reasoning_label); + } + message.push_str(" for "); + message.push_str(next_mode.display_name()); + message.push_str(" mode."); + self.add_info_message(message, None); + } self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d6d2ab1ad7..edf0e01681 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3584,6 +3584,66 @@ async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { assert_eq!(chat.active_collaboration_mode_kind(), before); } +#[tokio::test] +async fn mode_switch_surfaces_model_change_notification_when_effective_model_changes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let default_model = chat.current_model().to_string(); + + let mut plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mode"); + plan_mask.model = Some("gpt-5.1-codex-mini".to_string()); + chat.set_collaboration_mask(plan_mask); + + let plan_messages = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + plan_messages.contains("Model changed to gpt-5.1-codex-mini medium for Plan mode."), + "expected Plan-mode model switch notice, got: {plan_messages:?}" + ); + + let default_mask = collaboration_modes::default_mask(chat.models_manager.as_ref()) + .expect("expected default collaboration mode"); + chat.set_collaboration_mask(default_mask); + + let default_messages = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + let expected_default_message = + format!("Model changed to {default_model} default for Default mode."); + assert!( + default_messages.contains(&expected_default_message), + "expected Default-mode model switch notice, got: {default_messages:?}" + ); +} + +#[tokio::test] +async fn mode_switch_surfaces_reasoning_change_notification_when_model_stays_same() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); + + let plan_mask = collaboration_modes::plan_mask(chat.models_manager.as_ref()) + .expect("expected plan collaboration mode"); + chat.set_collaboration_mask(plan_mask); + + let plan_messages = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + plan_messages.contains("Model changed to gpt-5.3-codex medium for Plan mode."), + "expected reasoning-change notice in Plan mode, got: {plan_messages:?}" + ); +} + #[tokio::test] async fn collab_slash_command_opens_picker_and_updates_mode() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -3649,7 +3709,12 @@ async fn plan_slash_command_switches_to_plan_mode() { chat.dispatch_command(SlashCommand::Plan); - assert!(rx.try_recv().is_err(), "plan should not emit an app event"); + while let Ok(event) = rx.try_recv() { + assert!( + matches!(event, AppEvent::InsertHistoryCell(_)), + "plan should not emit a non-history app event: {event:?}" + ); + } assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); assert_eq!(chat.current_collaboration_mode(), &initial); } From 4ebdddaa3497e7fbd4cd80434015f016b9bdf119 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 20 Feb 2026 16:47:15 -0800 Subject: [PATCH 013/282] [apps] Fix gateway url. (#12403) - [x] Fix connectors gateway url. --- codex-rs/core/src/mcp/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index f2c2557514..3abe4e9233 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -31,7 +31,7 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__"; pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; const OPENAI_CONNECTORS_MCP_BASE_URL: &str = "https://api.openai.com"; -const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/mcp/"; +const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/gateways/flat/mcp"; // Legacy vs new MCP gateway #[derive(Debug, Clone, Copy, PartialEq, Eq)] From a5d0757ed15b4b53c216510c5bcfa4ecf3ca196e Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 20 Feb 2026 21:56:41 -0300 Subject: [PATCH 014/282] fix(tui): queued-message edit shortcut unreachable in some terminals (#12240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The TUI's "edit queued message" shortcut (Alt+Up) is either silently swallowed or recognized as another key combination by Apple Terminal, Warp, and VSCode's integrated terminal on macOS. Users in those environments see the hint but pressing the keys does nothing. ## Mental model When a model turn is in progress the user can still type follow-up messages. These are queued and displayed below the composer with a hint line showing how to pop the most recent one back into the editor. The hint text and the actual key handler must agree on which shortcut is used, and that shortcut must actually reach the TUI—i.e. it must not be intercepted by the host terminal. Three terminals are known to intercept Alt+Up: Apple Terminal (remaps it to cursor movement), Warp (consumes it for its own command palette), and VSCode (maps it to "move line up"). For these we use Shift+Left instead.

image

| macOS Native Terminal | Warp | VSCode Terminal | |---|---|---| | SCR-20260219-kigi | SCR-20260219-krrf | SCR-20260219-ksbz | ## Non-goals - Making the binding user-configurable at runtime (deferred to a broader keybinding-config effort). - Remapping any other shortcuts that might be terminal-specific. ## Tradeoffs - **Exhaustive match instead of a wildcard default.** The `queued_message_edit_binding_for_terminal` function explicitly lists every `TerminalName` variant. This is intentional: adding a new terminal to the enum will produce a compile error, forcing the author to decide which binding that terminal should use. - **Binding lives on `ChatWidget`, hint lives on `QueuedUserMessages`.** The key event handler that actually acts on the press is in `ChatWidget`, but the rendered hint text is inside `QueuedUserMessages`. These are kept in sync by `ChatWidget` calling `bottom_pane.set_queued_message_edit_binding(self.queued_message_edit_binding)` during construction. A mismatch would show the wrong hint but would not lose data. ## Architecture ```mermaid graph TD TI["terminal_info().name"] --> FN["queued_message_edit_binding_for_terminal(name)"] FN --> KB["KeyBinding"] KB --> CW["ChatWidget.queued_message_edit_binding
key event matching"] KB --> BP["BottomPane.set_queued_message_edit_binding()"] BP --> QUM["QueuedUserMessages.edit_binding
rendered in hint line"] subgraph "Special terminals (Shift+Left)" AT["Apple Terminal"] WT["Warp"] VS["VSCode"] end subgraph "Default (Alt+Up)" GH["Ghostty"] IT["iTerm2"] OT["Others…"] end AT --> FN WT --> FN VS --> FN GH --> FN IT --> FN OT --> FN ``` No new crates or public API surface. The only cross-crate dependency added is `codex_core::terminal::{TerminalName, terminal_info}`, which already existed for telemetry. ## Observability No new logging. Terminal detection already emits a `tracing::debug!` log line at startup with the detected terminal name, which is sufficient to diagnose binding mismatches. ## Tests - Existing `alt_up_edits_most_recent_queued_message` test is preserved and explicitly sets the Alt+Up binding to isolate from the host terminal. - New parameterized async tests verify Shift+Left works for Apple Terminal, Warp, and VSCode. - A sync unit test asserts the mapping table covers the three special terminals (Shift+Left) and that iTerm2 still gets Alt+Up. Fixes #4490 --- codex-rs/tui/src/bottom_pane/mod.rs | 7 ++ .../src/bottom_pane/queued_user_messages.rs | 18 ++++- codex-rs/tui/src/chatwidget.rs | 78 +++++++++++++++---- codex-rs/tui/src/chatwidget/tests.rs | 76 ++++++++++++++++++ 4 files changed, 165 insertions(+), 14 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 43aecb4940..46917dd9fa 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -287,6 +287,13 @@ impl BottomPane { self.request_redraw(); } + /// Update the key hint shown next to queued messages so it matches the + /// binding that `ChatWidget` actually listens for. + pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) { + self.queued_user_messages.set_edit_binding(binding); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } diff --git a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs index ae33aeada4..010b88111c 100644 --- a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs +++ b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs @@ -11,17 +11,33 @@ use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_lines; /// Widget that displays a list of user messages queued while a turn is in progress. +/// +/// The widget shows a key hint at the bottom (e.g. "⌥ + ↑ edit") telling the +/// user how to pop the most recent queued message back into the composer. +/// Because some terminals intercept certain modifier-key combinations, the +/// displayed binding is configurable via [`set_edit_binding`](Self::set_edit_binding). pub(crate) struct QueuedUserMessages { pub messages: Vec, + /// Key combination rendered in the hint line. Defaults to Alt+Up but may + /// be overridden for terminals where that chord is unavailable. + edit_binding: key_hint::KeyBinding, } impl QueuedUserMessages { pub(crate) fn new() -> Self { Self { messages: Vec::new(), + edit_binding: key_hint::alt(KeyCode::Up), } } + /// Replace the keybinding shown in the hint line at the bottom of the + /// queued-messages list. The caller is responsible for also wiring the + /// corresponding key event handler. + pub(crate) fn set_edit_binding(&mut self, binding: key_hint::KeyBinding) { + self.edit_binding = binding; + } + fn as_renderable(&self, width: u16) -> Box { if self.messages.is_empty() || width < 4 { return Box::new(()); @@ -48,7 +64,7 @@ impl QueuedUserMessages { lines.push( Line::from(vec![ " ".into(), - key_hint::alt(KeyCode::Up).into(), + self.edit_binding.into(), " edit".into(), ]) .dim(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ca5aa7c620..afc894b22b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -110,6 +110,8 @@ use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_core::skills::model::SkillMetadata; +use codex_core::terminal::TerminalName; +use codex_core::terminal::terminal_info; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_otel::OtelManager; @@ -158,6 +160,34 @@ const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; +/// Choose the keybinding used to edit the most-recently queued message. +/// +/// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently +/// swallow Alt+Up, so users in those environments would never be able to trigger +/// the edit action. We fall back to Shift+Left for those terminals while +/// keeping the more discoverable Alt+Up everywhere else. +/// +/// The match is exhaustive so that adding a new `TerminalName` variant forces +/// an explicit decision about which binding that terminal should use. +fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding { + match terminal_name { + TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { + key_hint::shift(KeyCode::Left) + } + TerminalName::Ghostty + | TerminalName::Iterm2 + | TerminalName::WezTerm + | TerminalName::Kitty + | TerminalName::Alacritty + | TerminalName::Konsole + | TerminalName::GnomeTerminal + | TerminalName::Vte + | TerminalName::WindowsTerminal + | TerminalName::Dumb + | TerminalName::Unknown => key_hint::alt(KeyCode::Up), + } +} + use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; @@ -563,6 +593,11 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + /// Terminal-appropriate keybinding for popping the most-recently queued + /// message back into the composer. Determined once at construction time via + /// [`queued_message_edit_binding_for_terminal`] and propagated to + /// `BottomPane` so the hint text matches the actual shortcut. + queued_message_edit_binding: KeyBinding, // Pending notification to show when unfocused on next Draw pending_notification: Option, /// When `Some`, the user has pressed a quit shortcut and the second press @@ -2605,6 +2640,8 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); + let queued_message_edit_binding = + queued_message_edit_binding_for_terminal(terminal_info().name); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -2662,6 +2699,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + queued_message_edit_binding, show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, pending_notification: None, @@ -2702,6 +2740,9 @@ impl ChatWidget { widget.config.features.enabled(Feature::CollaborationModes), ); widget.sync_personality_command_enabled(); + widget + .bottom_pane + .set_queued_message_edit_binding(widget.queued_message_edit_binding); #[cfg(target_os = "windows")] widget.bottom_pane.set_windows_degraded_sandbox_active( codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED @@ -2769,6 +2810,8 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); + let queued_message_edit_binding = + queued_message_edit_binding_for_terminal(terminal_info().name); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -2830,6 +2873,7 @@ impl ChatWidget { plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), + queued_message_edit_binding, show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, pending_notification: None, @@ -2866,6 +2910,9 @@ impl ChatWidget { widget.config.features.enabled(Feature::CollaborationModes), ); widget.sync_personality_command_enabled(); + widget + .bottom_pane + .set_queued_message_edit_binding(widget.queued_message_edit_binding); widget } @@ -2922,6 +2969,8 @@ impl ChatWidget { settings: fallback_default, }; + let queued_message_edit_binding = + queued_message_edit_binding_for_terminal(terminal_info().name); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -2979,6 +3028,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + queued_message_edit_binding, show_welcome_banner: false, suppress_session_configured_redraw: true, pending_notification: None, @@ -3019,6 +3069,9 @@ impl ChatWidget { widget.config.features.enabled(Feature::CollaborationModes), ); widget.sync_personality_command_enabled(); + widget + .bottom_pane + .set_queued_message_edit_binding(widget.queued_message_edit_binding); #[cfg(target_os = "windows")] widget.bottom_pane.set_windows_degraded_sandbox_active( codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED @@ -3091,6 +3144,18 @@ impl ChatWidget { _ => {} } + if key_event.kind == KeyEventKind::Press + && self.queued_message_edit_binding.is_press(key_event) + && !self.queued_user_messages.is_empty() + { + if let Some(user_message) = self.queued_user_messages.pop_back() { + self.restore_user_message_to_composer(user_message); + self.refresh_queued_user_messages(); + self.request_redraw(); + } + return; + } + match key_event { KeyEvent { code: KeyCode::BackTab, @@ -3102,19 +3167,6 @@ impl ChatWidget { { self.cycle_collaboration_mode(); } - KeyEvent { - code: KeyCode::Up, - modifiers: KeyModifiers::ALT, - kind: KeyEventKind::Press, - .. - } if !self.queued_user_messages.is_empty() => { - // Prefer the most recently queued item. - if let Some(user_message) = self.queued_user_messages.pop_back() { - self.restore_user_message_to_composer(user_message); - self.refresh_queued_user_messages(); - self.request_redraw(); - } - } _ => match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted { text, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index edf0e01681..8646033284 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -67,6 +67,7 @@ use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_core::skills::model::SkillMetadata; +use codex_core::terminal::TerminalName; use codex_otel::OtelManager; use codex_otel::RuntimeMetricsSummary; use codex_protocol::ThreadId; @@ -1640,6 +1641,7 @@ async fn make_chatwidget_manual( frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), + queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), suppress_session_configured_redraw: false, pending_notification: None, quit_shortcut_expires_at: None, @@ -2781,6 +2783,9 @@ async fn empty_enter_during_task_does_not_queue() { #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.queued_message_edit_binding = crate::key_hint::alt(KeyCode::Up); + chat.bottom_pane + .set_queued_message_edit_binding(crate::key_hint::alt(KeyCode::Up)); // Simulate a running task so messages would normally be queued. chat.bottom_pane.set_task_running(true); @@ -2808,6 +2813,77 @@ async fn alt_up_edits_most_recent_queued_message() { ); } +async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( + terminal_name: TerminalName, +) { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_name); + chat.bottom_pane + .set_queued_message_edit_binding(chat.queued_message_edit_binding); + + // Simulate a running task so messages would normally be queued. + chat.bottom_pane.set_task_running(true); + + // Seed two queued messages. + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_queued_user_messages(); + + // Press Shift+Left to edit the most recent (last) queued message. + chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT)); + + // Composer should now contain the last queued message. + assert_eq!( + chat.bottom_pane.composer_text(), + "second queued".to_string() + ); + // And the queue should now contain only the remaining (older) item. + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "first queued" + ); +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::AppleTerminal) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::WarpTerminal) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::VsCode).await; +} + +#[test] +fn queued_message_edit_binding_mapping_covers_special_terminals() { + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalName::VsCode), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalName::Iterm2), + crate::key_hint::alt(KeyCode::Up) + ); +} + /// Pressing Up to recall the most recent history entry and immediately queuing /// it while a task is running should always enqueue the same text, even when it /// is queued repeatedly. From 36a2a9fdbb11b07504a69c4f2fa90e63c1cbeb67 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 20 Feb 2026 17:35:07 -0800 Subject: [PATCH 015/282] [apps] Bump MCP tool call timeout. (#12405) - [x] Bump MCP tool call timeout. --- codex-rs/core/src/mcp_connection_manager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 05ac2a9a9b..12e3d325d3 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -92,7 +92,7 @@ const MAX_TOOL_NAME_LENGTH: usize = 64; pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); /// Default timeout for individual tool calls. -const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(60); +const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(120); const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 1; const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; From 53bcfaf42d996d4e93e66c86f41a55b7ff3b56fc Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 20 Feb 2026 17:51:53 -0800 Subject: [PATCH 016/282] fix: explicitly list name collisions in JSON schema generation (#12406) ## Why JSON schema codegen was silently resolving naming collisions by appending numeric suffixes (for example `...2`, `...3`). That makes the generated schema names unstable: removing an earlier colliding type can cause a later type to be renumbered, which is a breaking change for consumers that referenced the old generated name. This PR makes those collisions explicit and reviewable. Though note that once we remove `v1` from the codegen, we will no longer support naming collisions. Or rather, naming collisions will have to be handled explicitly rather than the numeric suffix approach. ## What Changed - In `codex-rs/app-server-protocol/src/export.rs`, replaced implicit numeric suffix collision handling for generated variant titles with explicit special-case maps. - Added a panic when a collision occurs without an entry in the map, so new collisions fail loudly instead of silently renaming generated schema types. - Added the currently required special cases so existing generated names remain stable. - Extended the same approach to numbered `definitions` / `$defs` collisions (for example `MessagePhase2`-style names) so those are also explicitly tracked. ## Verification - Ran targeted generator-path test: - `cargo test -p codex-app-server-protocol generate_json_filters_experimental_fields_and_methods -- --nocapture` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/12406). * #12408 * __->__ #12406 --- codex-rs/app-server-protocol/src/export.rs | 239 ++++++++++++++++++++- 1 file changed, 235 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index ec51dd1585..1a4edfdf57 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -32,10 +32,93 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::process::Command; +use std::sync::LazyLock; use ts_rs::TS; const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; +static VARIANT_TITLE_COLLISION_OVERRIDES: LazyLock> = + LazyLock::new(|| { + HashMap::from([ + ( + "base=McpStartupStatus|generated=StateMcpStartupStatus|literal:state=ready|only_property=state|required_only=state", + "StateMcpStartupStatus2", + ), + ( + "base=McpStartupStatus|generated=StateMcpStartupStatus|literal:state=cancelled|only_property=state|required_only=state", + "StateMcpStartupStatus3", + ), + ]) + }); +static NUMBERED_DEFINITION_COLLISION_OVERRIDES: LazyLock> = + LazyLock::new(|| { + HashMap::from([ + ( + "schema=ClientRequest|container=definitions|generated=AskForApproval2|base=AskForApproval", + "AskForApproval2", + ), + ( + "schema=ClientRequest|container=definitions|generated=NetworkAccess2|base=NetworkAccess", + "NetworkAccess2", + ), + ( + "schema=ClientRequest|container=definitions|generated=ReadOnlyAccess2|base=ReadOnlyAccess", + "ReadOnlyAccess2", + ), + ( + "schema=ClientRequest|container=definitions|generated=SandboxMode2|base=SandboxMode", + "SandboxMode2", + ), + ( + "schema=ClientRequest|container=definitions|generated=SandboxPolicy2|base=SandboxPolicy", + "SandboxPolicy2", + ), + ( + "schema=ServerNotification|container=definitions|generated=ByteRange2|base=ByteRange", + "ByteRange2", + ), + ( + "schema=ServerNotification|container=definitions|generated=CodexErrorInfo2|base=CodexErrorInfo", + "CodexErrorInfo2", + ), + ( + "schema=ServerNotification|container=definitions|generated=CreditsSnapshot2|base=CreditsSnapshot", + "CreditsSnapshot2", + ), + ( + "schema=ServerNotification|container=definitions|generated=MessagePhase2|base=MessagePhase", + "MessagePhase2", + ), + ( + "schema=ServerNotification|container=definitions|generated=ModelRerouteReason2|base=ModelRerouteReason", + "ModelRerouteReason2", + ), + ( + "schema=ServerNotification|container=definitions|generated=PatchApplyStatus2|base=PatchApplyStatus", + "PatchApplyStatus2", + ), + ( + "schema=ServerNotification|container=definitions|generated=RateLimitSnapshot2|base=RateLimitSnapshot", + "RateLimitSnapshot2", + ), + ( + "schema=ServerNotification|container=definitions|generated=RateLimitWindow2|base=RateLimitWindow", + "RateLimitWindow2", + ), + ( + "schema=ServerNotification|container=definitions|generated=TextElement2|base=TextElement", + "TextElement2", + ), + ( + "schema=ServerNotification|container=definitions|generated=UserInput2|base=UserInput", + "UserInput2", + ), + ( + "schema=ServerNotification|container=definitions|generated=WebSearchAction2|base=WebSearchAction", + "WebSearchAction2", + ), + ]) + }); #[derive(Clone)] pub struct GeneratedSchema { @@ -950,6 +1033,7 @@ where let file_stem = name.trim(); let schema = schema_for!(T); let mut schema_value = serde_json::to_value(schema)?; + enforce_numbered_definition_collision_overrides(file_stem, &mut schema_value); annotate_schema(&mut schema_value, Some(file_stem)); // If the name looks like a namespaced path (e.g., "v2::Type"), mirror // the TypeScript layout and write to out_dir/v2/Type.json. Otherwise @@ -980,6 +1064,101 @@ where }) } +fn enforce_numbered_definition_collision_overrides(schema_name: &str, schema: &mut Value) { + for defs_key in ["definitions", "$defs"] { + let renames = { + let Some(defs) = schema.get(defs_key).and_then(Value::as_object) else { + continue; + }; + collect_numbered_definition_collision_renames(schema_name, defs_key, defs) + }; + + if renames.is_empty() { + continue; + } + + #[expect(clippy::expect_used)] + let defs = schema + .get_mut(defs_key) + .and_then(Value::as_object_mut) + .expect("definition map should still be present"); + for (old_name, new_name, collision_key) in &renames { + if old_name == new_name { + continue; + } + assert!( + !defs.contains_key(new_name), + "Numbered definition collision override produced duplicate definition {new_name} for {collision_key}", + ); + let schema_value = defs.remove(old_name).unwrap_or_else(|| { + panic!("Missing generated numbered definition {old_name} for {collision_key}") + }); + defs.insert(new_name.clone(), schema_value); + } + + for (old_name, new_name, _) in renames { + if old_name != new_name { + rewrite_named_local_ref(schema, defs_key, &old_name, &new_name); + } + } + } +} + +fn collect_numbered_definition_collision_renames( + schema_name: &str, + defs_key: &str, + defs: &Map, +) -> Vec<(String, String, String)> { + let mut renames = Vec::new(); + + for generated_name in defs.keys() { + let base_name = generated_name.trim_end_matches(|c: char| c.is_ascii_digit()); + if base_name == generated_name || !defs.contains_key(base_name) { + continue; + } + + let collision_key = format!( + "schema={schema_name}|container={defs_key}|generated={generated_name}|base={base_name}" + ); + let replacement = NUMBERED_DEFINITION_COLLISION_OVERRIDES + .get(collision_key.as_str()) + .map(|value| (*value).to_string()) + .unwrap_or_else(|| { + panic!("Missing numbered definition collision override for {collision_key}") + }); + renames.push((generated_name.clone(), replacement, collision_key)); + } + + renames +} + +fn rewrite_named_local_ref(value: &mut Value, defs_key: &str, old_name: &str, new_name: &str) { + let direct = format!("#/{defs_key}/{old_name}"); + let prefixed = format!("{direct}/"); + let replacement = format!("#/{defs_key}/{new_name}"); + let replacement_prefixed = format!("{replacement}/"); + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get_mut("$ref") { + if reference == &direct { + *reference = replacement; + } else if let Some(rest) = reference.strip_prefix(&prefixed) { + *reference = format!("{replacement_prefixed}{rest}"); + } + } + for child in obj.values_mut() { + rewrite_named_local_ref(child, defs_key, old_name, new_name); + } + } + Value::Array(items) => { + for child in items { + rewrite_named_local_ref(child, defs_key, old_name, new_name); + } + } + _ => {} + } +} + pub(crate) fn write_json_schema(out_dir: &Path, name: &str) -> Result where T: JsonSchema, @@ -1198,10 +1377,20 @@ fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) { && let Some(name) = variant_definition_name(base_name, variant) { let mut candidate = name.clone(); - let mut index = 2; - while seen.contains(&candidate) { - candidate = format!("{name}{index}"); - index += 1; + if seen.contains(&candidate) { + let collision_key = variant_title_collision_key(base_name, &name, variant); + candidate = VARIANT_TITLE_COLLISION_OVERRIDES + .get(collision_key.as_str()) + .map(|value| (*value).to_string()) + .unwrap_or_else(|| { + panic!( + "Missing variant title collision override for {collision_key} (generated name: {name})" + ) + }); + assert!( + !seen.contains(&candidate), + "Variant title collision override produced duplicate title {candidate} for {collision_key}", + ); } if let Some(obj) = variant.as_object_mut() { obj.insert("title".into(), Value::String(candidate.clone())); @@ -1221,6 +1410,48 @@ fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) { } } +fn variant_title_collision_key(base: &str, generated_name: &str, variant: &Value) -> String { + let mut parts = vec![ + format!("base={base}"), + format!("generated={generated_name}"), + ]; + + if let Some(props) = variant.get("properties").and_then(Value::as_object) { + for key in DISCRIMINATOR_KEYS { + if let Some(value) = literal_from_property(props, key) { + parts.push(format!("{key}={value}")); + } + } + for (key, value) in props { + if DISCRIMINATOR_KEYS.contains(&key.as_str()) { + continue; + } + if let Some(literal) = string_literal(value) { + parts.push(format!("literal:{key}={literal}")); + } + } + + if props.len() == 1 + && let Some(key) = props.keys().next() + { + parts.push(format!("only_property={key}")); + } + } + + if let Some(required) = variant.get("required").and_then(Value::as_array) + && required.len() == 1 + && let Some(key) = required[0].as_str() + { + parts.push(format!("required_only={key}")); + } + + if parts.len() == 2 { + parts.push(format!("variant={variant}")); + } + + parts.join("|") +} + const DISCRIMINATOR_KEYS: &[&str] = &["type", "method", "mode", "status", "role", "reason"]; fn set_discriminator_titles(props: &mut Map, owner: &str) { From 936e744c939071367d1c5e89cb8a7a211cc6c18c Mon Sep 17 00:00:00 2001 From: natea-oai Date: Fri, 20 Feb 2026 18:26:57 -0800 Subject: [PATCH 017/282] Add field to Thread object for the latest rename set for a given thread (#12301) Exposes through the app server updated names set for a thread. This enables other surfaces to use the core as the source of truth for thread naming. `threadName` is gathered using the helper functions used to interact with `session_index.jsonl`, and is hydrated in: - `thread/list` - `thread/read` - `thread/resume` - `thread/unarchive` - `thread/rollback` We don't do this for `thread/start` and `thread/fork`. --- .../schema/json/ServerNotification.json | 7 + .../codex_app_server_protocol.schemas.json | 7 + .../schema/json/v2/ThreadForkResponse.json | 7 + .../schema/json/v2/ThreadListResponse.json | 7 + .../schema/json/v2/ThreadReadResponse.json | 7 + .../schema/json/v2/ThreadResumeResponse.json | 7 + .../json/v2/ThreadRollbackResponse.json | 7 + .../schema/json/v2/ThreadStartResponse.json | 7 + .../json/v2/ThreadStartedNotification.json | 7 + .../json/v2/ThreadUnarchiveResponse.json | 7 + .../schema/typescript/v2/Thread.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 2 + .../app-server/src/bespoke_event_handling.rs | 14 ++ .../app-server/src/codex_message_processor.rs | 60 ++++++- codex-rs/app-server/src/thread_status.rs | 1 + .../app-server/tests/common/mcp_process.rs | 10 ++ .../app-server/tests/suite/v2/thread_fork.rs | 24 +++ .../app-server/tests/suite/v2/thread_read.rs | 156 ++++++++++++++++++ .../tests/suite/v2/thread_rollback.rs | 14 ++ .../app-server/tests/suite/v2/thread_start.rs | 24 +++ .../tests/suite/v2/thread_unarchive.rs | 14 ++ 21 files changed, 386 insertions(+), 7 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6a6bf2d7c7..1df0a8b05c 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -6736,6 +6736,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 313c3e62b3..4b1728512e 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 @@ -15553,6 +15553,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index dad39a512a..55480c5bc7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -836,6 +836,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 80a1d42a23..9e4e4d63b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -609,6 +609,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index a4810d5850..acb80dd56f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -609,6 +609,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index c4e1df8478..884930d4d6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -836,6 +836,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index f8cdcd744c..555083baf2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -609,6 +609,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 099a65d219..2acd4da8fb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -836,6 +836,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 9927bb2f3c..a39ec5151c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -609,6 +609,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 011a97a34c..e18582f069 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -609,6 +609,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 5e7a6b7af4..ee07081dca 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -55,6 +55,10 @@ agentRole: string | null, * Optional Git metadata captured when the thread was created. */ gitInfo: GitInfo | null, +/** + * Optional user-facing thread title. + */ +name: string | null, /** * Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` * (when `includeTurns` is true) responses. diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 833e628214..4b3ffaf02f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2277,6 +2277,8 @@ pub struct Thread { pub agent_role: Option, /// Optional Git metadata captured when the thread was created. pub git_info: Option, + /// Optional user-facing thread title. + pub name: Option, /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` /// (when `includeTurns` is true) responses. /// For all other responses and notifications returning a Thread, diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index fca2bcc1c1..f53b10b843 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -75,6 +75,7 @@ use codex_app_server_protocol::build_turns_from_rollout_items; use codex_app_server_protocol::convert_patch_changes; use codex_core::CodexThread; use codex_core::ThreadManager; +use codex_core::find_thread_name_by_id; use codex_core::parse_command::shlex_join; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::CodexErrorInfo as CoreCodexErrorInfo; @@ -99,11 +100,13 @@ use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUse use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; use std::collections::HashMap; use std::convert::TryFrom; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; use tokio::sync::oneshot; use tracing::error; +use tracing::warn; type JsonValue = serde_json::Value; @@ -129,6 +132,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager: ThreadWatchManager, api_version: ApiVersion, fallback_model_provider: String, + codex_home: &Path, ) { let Event { id: event_turn_id, @@ -1224,6 +1228,16 @@ pub(crate) async fn apply_bespoke_event_handling( thread.status = thread_watch_manager .loaded_status_for_thread(&thread.id) .await; + match find_thread_name_by_id(codex_home, &conversation_id).await { + Ok(name) => { + thread.name = name; + } + Err(err) => { + warn!( + "Failed to read thread name for {conversation_id}: {err}" + ); + } + } ThreadRollbackResponse { thread } } Err(err) => { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 65b7f1ecb7..72dc7eaea2 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -202,6 +202,8 @@ use codex_core::features::FEATURES; use codex_core::features::Feature; use codex_core::features::Stage; use codex_core::find_archived_thread_path_by_id_str; +use codex_core::find_thread_name_by_id; +use codex_core::find_thread_names_by_ids; use codex_core::find_thread_path_by_id_str; use codex_core::git_info::git_diff_to_remote; use codex_core::mcp::collect_mcp_snapshot; @@ -2374,6 +2376,7 @@ impl CodexMessageProcessor { .thread_watch_manager .loaded_status_for_thread(&thread.id) .await; + self.attach_thread_name(thread_id, &mut thread).await; let thread_id = thread.id.clone(); let response = ThreadUnarchiveResponse { thread }; self.outgoing.send_response(request_id, response).await; @@ -2544,18 +2547,36 @@ impl CodexMessageProcessor { return; } }; + let mut threads = Vec::with_capacity(summaries.len()); + let mut thread_ids = HashSet::with_capacity(summaries.len()); + let mut status_ids = Vec::with_capacity(summaries.len()); + + for summary in summaries { + let conversation_id = summary.conversation_id; + thread_ids.insert(conversation_id); + + let thread = summary_to_thread(summary); + status_ids.push(thread.id.clone()); + threads.push((conversation_id, thread)); + } + + let names = match find_thread_names_by_ids(&self.config.codex_home, &thread_ids).await { + Ok(names) => names, + Err(err) => { + warn!("Failed to read thread names: {err}"); + HashMap::new() + } + }; - let data = summaries - .into_iter() - .map(summary_to_thread) - .collect::>(); let statuses = self .thread_watch_manager - .loaded_statuses_for_threads(data.iter().map(|thread| thread.id.clone()).collect()) + .loaded_statuses_for_threads(status_ids) .await; - let data = data + + let data = threads .into_iter() - .map(|mut thread| { + .map(|(conversation_id, mut thread)| { + thread.name = names.get(&conversation_id).cloned(); if let Some(status) = statuses.get(&thread.id) { thread.status = status.clone(); } @@ -2717,6 +2738,7 @@ impl CodexMessageProcessor { } build_thread_from_snapshot(thread_uuid, &config_snapshot, loaded_rollout_path) }; + self.attach_thread_name(thread_uuid, &mut thread).await; if include_turns && let Some(rollout_path) = rollout_path.as_ref() { match read_rollout_items_from_rollout(rollout_path).await { @@ -3206,6 +3228,7 @@ impl CodexMessageProcessor { match read_rollout_items_from_rollout(rollout_path).await { Ok(items) => { thread.turns = build_turns_from_rollout_items(&items); + self.attach_thread_name(thread_id, &mut thread).await; Some(thread) } Err(err) => { @@ -3222,6 +3245,17 @@ impl CodexMessageProcessor { } } + async fn attach_thread_name(&self, thread_id: ThreadId, thread: &mut Thread) { + match find_thread_name_by_id(&self.config.codex_home, &thread_id).await { + Ok(name) => { + thread.name = name; + } + Err(err) => { + warn!("Failed to read thread name for {thread_id}: {err}"); + } + } + } + async fn thread_fork(&mut self, request_id: ConnectionRequestId, params: ThreadForkParams) { let ThreadForkParams { thread_id, @@ -3419,6 +3453,7 @@ impl CodexMessageProcessor { return; } }; + // forked thread names do not inherit the source thread name match read_rollout_items_from_rollout(rollout_path.as_path()).await { Ok(items) => { thread.turns = build_turns_from_rollout_items(&items); @@ -5823,6 +5858,7 @@ impl CodexMessageProcessor { let thread_watch_manager = self.thread_watch_manager.clone(); let fallback_model_provider = self.config.model_provider_id.clone(); let single_client_mode = self.single_client_mode; + let codex_home = self.config.codex_home.clone(); tokio::spawn(async move { loop { tokio::select! { @@ -5903,6 +5939,7 @@ impl CodexMessageProcessor { thread_watch_manager.clone(), api_version, fallback_model_provider.clone(), + codex_home.as_path(), ) .await; } @@ -5916,6 +5953,7 @@ impl CodexMessageProcessor { ) => { handle_pending_thread_resume_request( conversation_id, + codex_home.as_path(), &thread_state, &thread_watch_manager, &outgoing_for_task, @@ -6235,6 +6273,7 @@ impl CodexMessageProcessor { async fn handle_pending_thread_resume_request( conversation_id: ThreadId, + codex_home: &Path, thread_state: &Arc>, thread_watch_manager: &ThreadWatchManager, outgoing: &Arc, @@ -6274,6 +6313,11 @@ async fn handle_pending_thread_resume_request( .loaded_status_for_thread(&thread.id) .await; + match find_thread_name_by_id(codex_home, &conversation_id).await { + Ok(thread_name) => thread.name = thread_name, + Err(err) => warn!("Failed to read thread name for {conversation_id}: {err}"), + } + let ThreadConfigSnapshot { model, model_provider_id, @@ -6994,6 +7038,7 @@ fn build_thread_from_snapshot( agent_role: config_snapshot.session_source.get_agent_role(), source: config_snapshot.session_source.clone().into(), git_info: None, + name: None, turns: Vec::new(), } } @@ -7034,6 +7079,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { agent_role: source.get_agent_role(), source: source.into(), git_info, + name: None, turns: Vec::new(), } } diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 301c4b1d07..48e3d9ab08 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -626,6 +626,7 @@ mod tests { agent_role: None, source, git_info: None, + name: None, turns: Vec::new(), } } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 249a280213..4e48f909b3 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -59,6 +59,7 @@ use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadUnarchiveParams; use codex_app_server_protocol::TurnCompletedNotification; @@ -418,6 +419,15 @@ impl McpProcess { self.send_request("thread/archive", params).await } + /// Send a `thread/name/set` JSON-RPC request. + pub async fn send_thread_set_name_request( + &mut self, + params: ThreadSetNameParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/name/set", params).await + } + /// Send a `thread/unarchive` JSON-RPC request. pub async fn send_thread_unarchive_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 7047622a44..a6f9942171 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use pretty_assertions::assert_eq; +use serde_json::Value; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -70,8 +71,20 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), ) .await??; + let fork_result = fork_resp.result.clone(); let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + // Wire contract: thread title field is `name`, serialized as null when unset. + let thread_json = fork_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/fork result.thread must be an object"); + assert_eq!( + thread_json.get("name"), + Some(&Value::Null), + "forked threads do not inherit a name; expected `name: null`" + ); + let after_contents = std::fs::read_to_string(&original_path)?; assert_eq!( after_contents, original_contents, @@ -87,6 +100,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { assert_ne!(thread_path, original_path); assert!(thread.cwd.is_absolute()); assert_eq!(thread.source, SessionSource::VsCode); + assert_eq!(thread.name, None); assert_eq!( thread.turns.len(), @@ -115,6 +129,16 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { mcp.read_stream_until_notification_message("thread/started"), ) .await??; + let started_params = notif.params.clone().expect("params must be present"); + let started_thread_json = started_params + .get("thread") + .and_then(Value::as_object) + .expect("thread/started params.thread must be an object"); + assert_eq!( + started_thread_json.get("name"), + Some(&Value::Null), + "thread/started must serialize `name: null` when unset" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; assert_eq!(started.thread, thread); 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 5a6925bcb8..384aad6c86 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -8,8 +8,14 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadSetNameResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; @@ -21,6 +27,7 @@ use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use core_test_support::responses; use pretty_assertions::assert_eq; +use serde_json::Value; use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; @@ -192,6 +199,155 @@ async fn thread_read_loaded_thread_returns_precomputed_path_before_materializati Ok(()) } +#[tokio::test] +async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let preview = "Saved user message"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + vec![], + Some("mock_provider"), + None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // `thread/name/set` operates on loaded threads (via ThreadManager). A rollout existing on disk + // is not enough; we must `thread/resume` first to load it into the running server. + let pre_resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let pre_resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(pre_resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: pre_resumed, + .. + } = to_response::(pre_resume_resp)?; + assert_eq!(pre_resumed.id, conversation_id); + + // Set a user-facing thread title. + let new_name = "My renamed thread"; + let set_id = mcp + .send_thread_set_name_request(ThreadSetNameParams { + thread_id: conversation_id.clone(), + name: new_name.to_string(), + }) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let _: ThreadSetNameResponse = to_response::(set_resp)?; + + // Read should now surface `thread.name`, and the wire payload must include `name`. + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: conversation_id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read_result = read_resp.result.clone(); + let ThreadReadResponse { thread } = to_response::(read_resp)?; + assert_eq!(thread.id, conversation_id); + assert_eq!(thread.name.as_deref(), Some(new_name)); + let thread_json = read_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/read result.thread must be an object"); + assert_eq!( + thread_json.get("name").and_then(Value::as_str), + Some(new_name), + "thread/read must serialize `thread.name` on the wire" + ); + + // List should also surface the name. + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(50), + sort_key: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: None, + }) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let list_result = list_resp.result.clone(); + let ThreadListResponse { data, .. } = to_response::(list_resp)?; + let listed = data + .iter() + .find(|t| t.id == conversation_id) + .expect("thread/list should include the created thread"); + assert_eq!(listed.name.as_deref(), Some(new_name)); + let listed_json = list_result + .get("data") + .and_then(Value::as_array) + .expect("thread/list result.data must be an array") + .iter() + .find(|t| t.get("id").and_then(Value::as_str) == Some(&conversation_id)) + .and_then(Value::as_object) + .expect("thread/list should include the created thread as an object"); + assert_eq!( + listed_json.get("name").and_then(Value::as_str), + Some(new_name), + "thread/list must serialize `thread.name` on the wire" + ); + + // Resume should also surface the name. + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let resume_result = resume_resp.result.clone(); + let ThreadResumeResponse { + thread: resumed, .. + } = to_response::(resume_resp)?; + assert_eq!(resumed.id, conversation_id); + assert_eq!(resumed.name.as_deref(), Some(new_name)); + let resumed_json = resume_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/resume result.thread must be an object"); + assert_eq!( + resumed_json.get("name").and_then(Value::as_str), + Some(new_name), + "thread/resume must serialize `thread.name` on the wire" + ); + + Ok(()) +} + #[tokio::test] async fn thread_read_include_turns_rejects_unmaterialized_loaded_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs index 47a3587281..3487b9e36a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs @@ -16,6 +16,7 @@ use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput as V2UserInput; use pretty_assertions::assert_eq; +use serde_json::Value; use tempfile::TempDir; use tokio::time::timeout; @@ -107,10 +108,23 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() mcp.read_stream_until_response_message(RequestId::Integer(rollback_id)), ) .await??; + let rollback_result = rollback_resp.result.clone(); let ThreadRollbackResponse { thread: rolled_back_thread, } = to_response::(rollback_resp)?; + // Wire contract: thread title field is `name`, serialized as null when unset. + let thread_json = rollback_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/rollback result.thread must be an object"); + assert_eq!(rolled_back_thread.name, None); + assert_eq!( + thread_json.get("name"), + Some(&Value::Null), + "thread/rollback must serialize `name: null` when unset" + ); + assert_eq!(rolled_back_thread.turns.len(), 1); assert_eq!(rolled_back_thread.status, ThreadStatus::Idle); assert_eq!(rolled_back_thread.turns[0].items.len(), 2); diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index bcee63df95..4e63d4c546 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -13,6 +13,7 @@ use codex_app_server_protocol::ThreadStatus; use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; +use serde_json::Value; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -45,6 +46,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(req_id)), ) .await??; + let resp_result = resp.result.clone(); let ThreadStartResponse { thread, model_provider, @@ -68,12 +70,34 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { "fresh thread rollout should not be materialized until first user message" ); + // Wire contract: thread title field is `name`, serialized as null when unset. + let thread_json = resp_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/start result.thread must be an object"); + assert_eq!( + thread_json.get("name"), + Some(&Value::Null), + "new threads should serialize `name: null`" + ); + assert_eq!(thread.name, None); + // A corresponding thread/started notification should arrive. let notif: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("thread/started"), ) .await??; + let started_params = notif.params.clone().expect("params must be present"); + let started_thread_json = started_params + .get("thread") + .and_then(Value::as_object) + .expect("thread/started params.thread must be an object"); + assert_eq!( + started_thread_json.get("name"), + Some(&Value::Null), + "thread/started should serialize `name: null` for new threads" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; assert_eq!(started.thread, thread); 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 35de7eee91..b2ae60ae35 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::UserInput; use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_path_by_id_str; use pretty_assertions::assert_eq; +use serde_json::Value; use std::fs::FileTimes; use std::fs::OpenOptions; use std::path::Path; @@ -120,6 +121,7 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result mcp.read_stream_until_response_message(RequestId::Integer(unarchive_id)), ) .await??; + let unarchive_result = unarchive_resp.result.clone(); let ThreadUnarchiveResponse { thread: unarchived_thread, } = to_response::(unarchive_resp)?; @@ -140,6 +142,18 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result ); assert_eq!(unarchived_thread.status, ThreadStatus::NotLoaded); + // Wire contract: thread title field is `name`, serialized as null when unset. + let thread_json = unarchive_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/unarchive result.thread must be an object"); + assert_eq!(unarchived_thread.name, None); + assert_eq!( + thread_json.get("name"), + Some(&Value::Null), + "thread/unarchive must serialize `name: null` when unset" + ); + let rollout_path_display = rollout_path.display(); assert!( rollout_path.exists(), From 6817f0be8aaa6e7215a044ca9462eb6eeece6c53 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 20 Feb 2026 19:06:35 -0800 Subject: [PATCH 018/282] Wire realtime api to core (#12268) - Introduce `RealtimeConversationManager` for realtime API management - Add `op::conversation` to start conversation, insert audio, insert text, and close conversation. - emit conversation lifecycle and realtime events. - Move shared realtime payload types into codex-protocol and add core e2e websocket tests for start/replace/transport-close paths. Things to consider: - Should we use the same `op::` and `Events` channel to carry audio? I think we should try this simple approach and later we can create separate one if the channels got congested. - Sending text updates to the client: we can start simple and later restrict that. - Provider auth isn't wired for now intentionally --- .../schema/json/EventMsg.json | 248 ++++++++++++ .../schema/json/ServerNotification.json | 181 +++++++++ .../codex_app_server_protocol.schemas.json | 181 +++++++++ .../json/v1/ForkConversationResponse.json | 181 +++++++++ .../json/v1/ResumeConversationResponse.json | 181 +++++++++ .../v1/SessionConfiguredNotification.json | 181 +++++++++ .../schema/typescript/EventMsg.ts | 5 +- .../schema/typescript/RealtimeAudioFrame.ts | 5 + .../RealtimeConversationClosedEvent.ts | 5 + .../RealtimeConversationRealtimeEvent.ts | 6 + .../RealtimeConversationStartedEvent.ts | 5 + .../schema/typescript/RealtimeEvent.ts | 7 + .../schema/typescript/index.ts | 5 + .../endpoint/realtime_websocket/methods.rs | 9 +- .../src/endpoint/realtime_websocket/mod.rs | 4 +- .../endpoint/realtime_websocket/protocol.rs | 22 +- codex-rs/codex-api/src/lib.rs | 4 +- .../codex-api/tests/realtime_websocket_e2e.rs | 16 +- codex-rs/core/src/codex.rs | 43 ++- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/realtime_conversation.rs | 354 +++++++++++++++++ codex-rs/core/src/rollout/policy.rs | 3 + codex-rs/core/tests/suite/mod.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 360 ++++++++++++++++++ .../src/event_processor_with_human_output.rs | 3 + codex-rs/mcp-server/src/codex_tool_runner.rs | 3 + codex-rs/protocol/src/protocol.rs | 127 ++++++ codex-rs/tui/src/chatwidget.rs | 3 + 28 files changed, 2102 insertions(+), 42 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/RealtimeAudioFrame.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/RealtimeConversationClosedEvent.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/RealtimeConversationRealtimeEvent.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/RealtimeConversationStartedEvent.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/RealtimeEvent.ts create mode 100644 codex-rs/core/src/realtime_conversation.rs create mode 100644 codex-rs/core/tests/suite/realtime_conversation.rs diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 33f58d18b1..518d650208 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -556,6 +556,73 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Realtime conversation lifecycle start event.", + "properties": { + "session_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_started" + ], + "title": "RealtimeConversationStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationStartedEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation streaming payload event.", + "properties": { + "payload": { + "$ref": "#/definitions/RealtimeEvent" + }, + "type": { + "enum": [ + "realtime_conversation_realtime" + ], + "title": "RealtimeConversationRealtimeEventMsgType", + "type": "string" + } + }, + "required": [ + "payload", + "type" + ], + "title": "RealtimeConversationRealtimeEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation lifecycle close event.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_closed" + ], + "title": "RealtimeConversationClosedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationClosedEventMsg", + "type": "object" + }, { "description": "Model routing changed from the requested model to a different model.", "properties": { @@ -3856,6 +3923,120 @@ } ] }, + "RealtimeAudioFrame": { + "properties": { + "data": { + "type": "string" + }, + "num_channels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sample_rate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samples_per_channel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "num_channels", + "sample_rate" + ], + "type": "object" + }, + "RealtimeEvent": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "SessionCreated": { + "properties": { + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ], + "type": "object" + } + }, + "required": [ + "SessionCreated" + ], + "title": "SessionCreatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "SessionUpdated": { + "properties": { + "backend_prompt": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "SessionUpdated" + ], + "title": "SessionUpdatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "AudioOut": { + "$ref": "#/definitions/RealtimeAudioFrame" + } + }, + "required": [ + "AudioOut" + ], + "title": "AudioOutRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ConversationItemAdded": true + }, + "required": [ + "ConversationItemAdded" + ], + "title": "ConversationItemAddedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Error": { + "type": "string" + } + }, + "required": [ + "Error" + ], + "title": "ErrorRealtimeEvent", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -5620,6 +5801,73 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Realtime conversation lifecycle start event.", + "properties": { + "session_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_started" + ], + "title": "RealtimeConversationStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationStartedEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation streaming payload event.", + "properties": { + "payload": { + "$ref": "#/definitions/RealtimeEvent" + }, + "type": { + "enum": [ + "realtime_conversation_realtime" + ], + "title": "RealtimeConversationRealtimeEventMsgType", + "type": "string" + } + }, + "required": [ + "payload", + "type" + ], + "title": "RealtimeConversationRealtimeEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation lifecycle close event.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_closed" + ], + "title": "RealtimeConversationClosedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationClosedEventMsg", + "type": "object" + }, { "description": "Model routing changed from the requested model to a different model.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 1df0a8b05c..51c445bf14 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1372,6 +1372,73 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Realtime conversation lifecycle start event.", + "properties": { + "session_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_started" + ], + "title": "RealtimeConversationStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationStartedEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation streaming payload event.", + "properties": { + "payload": { + "$ref": "#/definitions/RealtimeEvent" + }, + "type": { + "enum": [ + "realtime_conversation_realtime" + ], + "title": "RealtimeConversationRealtimeEventMsgType", + "type": "string" + } + }, + "required": [ + "payload", + "type" + ], + "title": "RealtimeConversationRealtimeEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation lifecycle close event.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_closed" + ], + "title": "RealtimeConversationClosedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationClosedEventMsg", + "type": "object" + }, { "description": "Model routing changed from the requested model to a different model.", "properties": { @@ -5176,6 +5243,120 @@ } ] }, + "RealtimeAudioFrame": { + "properties": { + "data": { + "type": "string" + }, + "num_channels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sample_rate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samples_per_channel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "num_channels", + "sample_rate" + ], + "type": "object" + }, + "RealtimeEvent": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "SessionCreated": { + "properties": { + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ], + "type": "object" + } + }, + "required": [ + "SessionCreated" + ], + "title": "SessionCreatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "SessionUpdated": { + "properties": { + "backend_prompt": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "SessionUpdated" + ], + "title": "SessionUpdatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "AudioOut": { + "$ref": "#/definitions/RealtimeAudioFrame" + } + }, + "required": [ + "AudioOut" + ], + "title": "AudioOutRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ConversationItemAdded": true + }, + "required": [ + "ConversationItemAdded" + ], + "title": "ConversationItemAddedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Error": { + "type": "string" + } + }, + "required": [ + "Error" + ], + "title": "ErrorRealtimeEvent", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ 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 4b1728512e..2c1eb40316 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 @@ -2611,6 +2611,73 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Realtime conversation lifecycle start event.", + "properties": { + "session_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_started" + ], + "title": "RealtimeConversationStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationStartedEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation streaming payload event.", + "properties": { + "payload": { + "$ref": "#/definitions/RealtimeEvent" + }, + "type": { + "enum": [ + "realtime_conversation_realtime" + ], + "title": "RealtimeConversationRealtimeEventMsgType", + "type": "string" + } + }, + "required": [ + "payload", + "type" + ], + "title": "RealtimeConversationRealtimeEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation lifecycle close event.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_closed" + ], + "title": "RealtimeConversationClosedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationClosedEventMsg", + "type": "object" + }, { "description": "Model routing changed from the requested model to a different model.", "properties": { @@ -7027,6 +7094,120 @@ } ] }, + "RealtimeAudioFrame": { + "properties": { + "data": { + "type": "string" + }, + "num_channels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sample_rate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samples_per_channel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "num_channels", + "sample_rate" + ], + "type": "object" + }, + "RealtimeEvent": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "SessionCreated": { + "properties": { + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ], + "type": "object" + } + }, + "required": [ + "SessionCreated" + ], + "title": "SessionCreatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "SessionUpdated": { + "properties": { + "backend_prompt": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "SessionUpdated" + ], + "title": "SessionUpdatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "AudioOut": { + "$ref": "#/definitions/RealtimeAudioFrame" + } + }, + "required": [ + "AudioOut" + ], + "title": "AudioOutRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ConversationItemAdded": true + }, + "required": [ + "ConversationItemAdded" + ], + "title": "ConversationItemAddedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Error": { + "type": "string" + } + }, + "required": [ + "Error" + ], + "title": "ErrorRealtimeEvent", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index f88d5f741b..70521ec729 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -556,6 +556,73 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Realtime conversation lifecycle start event.", + "properties": { + "session_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_started" + ], + "title": "RealtimeConversationStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationStartedEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation streaming payload event.", + "properties": { + "payload": { + "$ref": "#/definitions/RealtimeEvent" + }, + "type": { + "enum": [ + "realtime_conversation_realtime" + ], + "title": "RealtimeConversationRealtimeEventMsgType", + "type": "string" + } + }, + "required": [ + "payload", + "type" + ], + "title": "RealtimeConversationRealtimeEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation lifecycle close event.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_closed" + ], + "title": "RealtimeConversationClosedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationClosedEventMsg", + "type": "object" + }, { "description": "Model routing changed from the requested model to a different model.", "properties": { @@ -3856,6 +3923,120 @@ } ] }, + "RealtimeAudioFrame": { + "properties": { + "data": { + "type": "string" + }, + "num_channels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sample_rate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samples_per_channel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "num_channels", + "sample_rate" + ], + "type": "object" + }, + "RealtimeEvent": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "SessionCreated": { + "properties": { + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ], + "type": "object" + } + }, + "required": [ + "SessionCreated" + ], + "title": "SessionCreatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "SessionUpdated": { + "properties": { + "backend_prompt": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "SessionUpdated" + ], + "title": "SessionUpdatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "AudioOut": { + "$ref": "#/definitions/RealtimeAudioFrame" + } + }, + "required": [ + "AudioOut" + ], + "title": "AudioOutRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ConversationItemAdded": true + }, + "required": [ + "ConversationItemAdded" + ], + "title": "ConversationItemAddedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Error": { + "type": "string" + } + }, + "required": [ + "Error" + ], + "title": "ErrorRealtimeEvent", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index e619dfe126..3ce7593460 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -556,6 +556,73 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Realtime conversation lifecycle start event.", + "properties": { + "session_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_started" + ], + "title": "RealtimeConversationStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationStartedEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation streaming payload event.", + "properties": { + "payload": { + "$ref": "#/definitions/RealtimeEvent" + }, + "type": { + "enum": [ + "realtime_conversation_realtime" + ], + "title": "RealtimeConversationRealtimeEventMsgType", + "type": "string" + } + }, + "required": [ + "payload", + "type" + ], + "title": "RealtimeConversationRealtimeEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation lifecycle close event.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_closed" + ], + "title": "RealtimeConversationClosedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationClosedEventMsg", + "type": "object" + }, { "description": "Model routing changed from the requested model to a different model.", "properties": { @@ -3856,6 +3923,120 @@ } ] }, + "RealtimeAudioFrame": { + "properties": { + "data": { + "type": "string" + }, + "num_channels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sample_rate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samples_per_channel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "num_channels", + "sample_rate" + ], + "type": "object" + }, + "RealtimeEvent": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "SessionCreated": { + "properties": { + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ], + "type": "object" + } + }, + "required": [ + "SessionCreated" + ], + "title": "SessionCreatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "SessionUpdated": { + "properties": { + "backend_prompt": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "SessionUpdated" + ], + "title": "SessionUpdatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "AudioOut": { + "$ref": "#/definitions/RealtimeAudioFrame" + } + }, + "required": [ + "AudioOut" + ], + "title": "AudioOutRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ConversationItemAdded": true + }, + "required": [ + "ConversationItemAdded" + ], + "title": "ConversationItemAddedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Error": { + "type": "string" + } + }, + "required": [ + "Error" + ], + "title": "ErrorRealtimeEvent", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index 61887b4007..a3010337bb 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -556,6 +556,73 @@ "title": "WarningEventMsg", "type": "object" }, + { + "description": "Realtime conversation lifecycle start event.", + "properties": { + "session_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_started" + ], + "title": "RealtimeConversationStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationStartedEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation streaming payload event.", + "properties": { + "payload": { + "$ref": "#/definitions/RealtimeEvent" + }, + "type": { + "enum": [ + "realtime_conversation_realtime" + ], + "title": "RealtimeConversationRealtimeEventMsgType", + "type": "string" + } + }, + "required": [ + "payload", + "type" + ], + "title": "RealtimeConversationRealtimeEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation lifecycle close event.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_closed" + ], + "title": "RealtimeConversationClosedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationClosedEventMsg", + "type": "object" + }, { "description": "Model routing changed from the requested model to a different model.", "properties": { @@ -3856,6 +3923,120 @@ } ] }, + "RealtimeAudioFrame": { + "properties": { + "data": { + "type": "string" + }, + "num_channels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sample_rate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samples_per_channel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "num_channels", + "sample_rate" + ], + "type": "object" + }, + "RealtimeEvent": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "SessionCreated": { + "properties": { + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ], + "type": "object" + } + }, + "required": [ + "SessionCreated" + ], + "title": "SessionCreatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "SessionUpdated": { + "properties": { + "backend_prompt": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "SessionUpdated" + ], + "title": "SessionUpdatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "AudioOut": { + "$ref": "#/definitions/RealtimeAudioFrame" + } + }, + "required": [ + "AudioOut" + ], + "title": "AudioOutRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ConversationItemAdded": true + }, + "required": [ + "ConversationItemAdded" + ], + "title": "ConversationItemAddedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Error": { + "type": "string" + } + }, + "required": [ + "Error" + ], + "title": "ErrorRealtimeEvent", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts index 5ed3ca2529..3aa06058e0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts +++ b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts @@ -47,6 +47,9 @@ import type { PatchApplyBeginEvent } from "./PatchApplyBeginEvent"; import type { PatchApplyEndEvent } from "./PatchApplyEndEvent"; import type { PlanDeltaEvent } from "./PlanDeltaEvent"; import type { RawResponseItemEvent } from "./RawResponseItemEvent"; +import type { RealtimeConversationClosedEvent } from "./RealtimeConversationClosedEvent"; +import type { RealtimeConversationRealtimeEvent } from "./RealtimeConversationRealtimeEvent"; +import type { RealtimeConversationStartedEvent } from "./RealtimeConversationStartedEvent"; import type { ReasoningContentDeltaEvent } from "./ReasoningContentDeltaEvent"; import type { ReasoningRawContentDeltaEvent } from "./ReasoningRawContentDeltaEvent"; import type { RemoteSkillDownloadedEvent } from "./RemoteSkillDownloadedEvent"; @@ -75,4 +78,4 @@ import type { WebSearchEndEvent } from "./WebSearchEndEvent"; * Response event from the agent * NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. */ -export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; +export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "realtime_conversation_started" } & RealtimeConversationStartedEvent | { "type": "realtime_conversation_realtime" } & RealtimeConversationRealtimeEvent | { "type": "realtime_conversation_closed" } & RealtimeConversationClosedEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; diff --git a/codex-rs/app-server-protocol/schema/typescript/RealtimeAudioFrame.ts b/codex-rs/app-server-protocol/schema/typescript/RealtimeAudioFrame.ts new file mode 100644 index 0000000000..99c0c1063c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RealtimeAudioFrame.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 RealtimeAudioFrame = { data: string, sample_rate: number, num_channels: number, samples_per_channel: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationClosedEvent.ts b/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationClosedEvent.ts new file mode 100644 index 0000000000..c73e6833ae --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationClosedEvent.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 RealtimeConversationClosedEvent = { reason: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationRealtimeEvent.ts b/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationRealtimeEvent.ts new file mode 100644 index 0000000000..4ff24a8281 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationRealtimeEvent.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 { RealtimeEvent } from "./RealtimeEvent"; + +export type RealtimeConversationRealtimeEvent = { payload: RealtimeEvent, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationStartedEvent.ts b/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationStartedEvent.ts new file mode 100644 index 0000000000..f2894fcb1e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationStartedEvent.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 RealtimeConversationStartedEvent = { session_id: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/RealtimeEvent.ts b/codex-rs/app-server-protocol/schema/typescript/RealtimeEvent.ts new file mode 100644 index 0000000000..6297beebef --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RealtimeEvent.ts @@ -0,0 +1,7 @@ +// 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 { RealtimeAudioFrame } from "./RealtimeAudioFrame"; +import type { JsonValue } from "./serde_json/JsonValue"; + +export type RealtimeEvent = { "SessionCreated": { session_id: string, } } | { "SessionUpdated": { backend_prompt: string | null, } } | { "AudioOut": RealtimeAudioFrame } | { "ConversationItemAdded": JsonValue } | { "Error": string }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index cda04def82..30197f8922 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -148,6 +148,11 @@ export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; export type { RawResponseItemEvent } from "./RawResponseItemEvent"; export type { ReadOnlyAccess } from "./ReadOnlyAccess"; +export type { RealtimeAudioFrame } from "./RealtimeAudioFrame"; +export type { RealtimeConversationClosedEvent } from "./RealtimeConversationClosedEvent"; +export type { RealtimeConversationRealtimeEvent } from "./RealtimeConversationRealtimeEvent"; +export type { RealtimeConversationStartedEvent } from "./RealtimeConversationStartedEvent"; +export type { RealtimeEvent } from "./RealtimeEvent"; export type { ReasoningContentDeltaEvent } from "./ReasoningContentDeltaEvent"; export type { ReasoningEffort } from "./ReasoningEffort"; export type { ReasoningItem } from "./ReasoningItem"; diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index e9a297de07..48cc1d7002 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -372,7 +372,8 @@ impl RealtimeWebsocketClient { default_headers: HeaderMap, ) -> Result { ensure_rustls_crypto_provider(); - let ws_url = websocket_url_from_api_url(config.api_url.as_str())?; + // Keep provider base_url semantics aligned with HTTP clients; derive the ws endpoint here. + let ws_url = websocket_url_from_api_url(self.provider.base_url.as_str())?; let mut request = ws_url .as_str() @@ -638,7 +639,7 @@ mod tests { let provider = Provider { name: "test".to_string(), - base_url: "http://localhost".to_string(), + base_url: format!("http://{addr}"), query_params: Some(HashMap::new()), headers: HeaderMap::new(), retry: crate::provider::RetryConfig { @@ -654,7 +655,6 @@ mod tests { let connection = client .connect( RealtimeSessionConfig { - api_url: format!("ws://{addr}"), prompt: "backend prompt".to_string(), session_id: Some("conv_1".to_string()), }, @@ -765,7 +765,7 @@ mod tests { let provider = Provider { name: "test".to_string(), - base_url: "http://localhost".to_string(), + base_url: format!("http://{addr}"), query_params: Some(HashMap::new()), headers: HeaderMap::new(), retry: crate::provider::RetryConfig { @@ -781,7 +781,6 @@ mod tests { let connection = client .connect( RealtimeSessionConfig { - api_url: format!("ws://{addr}"), prompt: "backend prompt".to_string(), session_id: Some("conv_1".to_string()), }, diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/mod.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/mod.rs index 469fea8dcf..a89dbd3e77 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/mod.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/mod.rs @@ -1,10 +1,10 @@ pub mod methods; pub mod protocol; +pub use codex_protocol::protocol::RealtimeAudioFrame; +pub use codex_protocol::protocol::RealtimeEvent; pub use methods::RealtimeWebsocketClient; pub use methods::RealtimeWebsocketConnection; pub use methods::RealtimeWebsocketEvents; pub use methods::RealtimeWebsocketWriter; -pub use protocol::RealtimeAudioFrame; -pub use protocol::RealtimeEvent; pub use protocol::RealtimeSessionConfig; diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs index db63f51795..f2f0616fc6 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs @@ -1,33 +1,15 @@ -use serde::Deserialize; +pub use codex_protocol::protocol::RealtimeAudioFrame; +pub use codex_protocol::protocol::RealtimeEvent; use serde::Serialize; use serde_json::Value; use tracing::debug; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RealtimeSessionConfig { - pub api_url: String, pub prompt: String, pub session_id: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RealtimeAudioFrame { - pub data: String, - pub sample_rate: u32, - pub num_channels: u16, - #[serde(skip_serializing_if = "Option::is_none")] - pub samples_per_channel: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RealtimeEvent { - SessionCreated { session_id: String }, - SessionUpdated { backend_prompt: Option }, - AudioOut(RealtimeAudioFrame), - ConversationItemAdded(Value), - Error(String), -} - #[derive(Debug, Clone, Serialize)] #[serde(tag = "type")] pub(super) enum RealtimeOutboundMessage { diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index ff8953c034..23c08d7e2b 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -29,8 +29,6 @@ pub use crate::endpoint::aggregate::AggregateStreamExt; pub use crate::endpoint::compact::CompactClient; pub use crate::endpoint::memories::MemoriesClient; pub use crate::endpoint::models::ModelsClient; -pub use crate::endpoint::realtime_websocket::RealtimeAudioFrame; -pub use crate::endpoint::realtime_websocket::RealtimeEvent; pub use crate::endpoint::realtime_websocket::RealtimeSessionConfig; pub use crate::endpoint::realtime_websocket::RealtimeWebsocketClient; pub use crate::endpoint::realtime_websocket::RealtimeWebsocketConnection; @@ -44,3 +42,5 @@ pub use crate::provider::is_azure_responses_wire_base_url; pub use crate::sse::stream_from_fixture; pub use crate::telemetry::SseTelemetry; pub use crate::telemetry::WebsocketTelemetry; +pub use codex_protocol::protocol::RealtimeAudioFrame; +pub use codex_protocol::protocol::RealtimeEvent; diff --git a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs index 08d93b9145..ae8b082b93 100644 --- a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs +++ b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs @@ -50,10 +50,10 @@ where (addr, server) } -fn test_provider() -> Provider { +fn test_provider(base_url: String) -> Provider { Provider { name: "test".to_string(), - base_url: "http://localhost".to_string(), + base_url, query_params: Some(HashMap::new()), headers: HeaderMap::new(), retry: RetryConfig { @@ -124,11 +124,10 @@ async fn realtime_ws_e2e_session_create_and_event_flow() { }) .await; - let client = RealtimeWebsocketClient::new(test_provider()); + let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); let connection = client .connect( RealtimeSessionConfig { - api_url: format!("ws://{addr}"), prompt: "backend prompt".to_string(), session_id: Some("conv_123".to_string()), }, @@ -215,11 +214,10 @@ async fn realtime_ws_e2e_send_while_next_event_waits() { }) .await; - let client = RealtimeWebsocketClient::new(test_provider()); + let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); let connection = client .connect( RealtimeSessionConfig { - api_url: format!("ws://{addr}"), prompt: "backend prompt".to_string(), session_id: Some("conv_123".to_string()), }, @@ -277,11 +275,10 @@ async fn realtime_ws_e2e_disconnected_emitted_once() { }) .await; - let client = RealtimeWebsocketClient::new(test_provider()); + let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); let connection = client .connect( RealtimeSessionConfig { - api_url: format!("ws://{addr}"), prompt: "backend prompt".to_string(), session_id: Some("conv_123".to_string()), }, @@ -337,11 +334,10 @@ async fn realtime_ws_e2e_ignores_unknown_text_events() { }) .await; - let client = RealtimeWebsocketClient::new(test_provider()); + let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); let connection = client .connect( RealtimeSessionConfig { - api_url: format!("ws://{addr}"), prompt: "backend prompt".to_string(), session_id: Some("conv_123".to_string()), }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index fcddf4e83e..88e6fef893 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -30,6 +30,11 @@ use crate::features::maybe_push_unstable_features_warning; use crate::models_manager::manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; +use crate::realtime_conversation::RealtimeConversationManager; +use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio; +use crate::realtime_conversation::handle_close as handle_realtime_conversation_close; +use crate::realtime_conversation::handle_start as handle_realtime_conversation_start; +use crate::realtime_conversation::handle_text as handle_realtime_conversation_text; use crate::rollout::session_index; use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::handle_non_tool_response_item; @@ -282,7 +287,7 @@ pub struct CodexSpawnOk { } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; -pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 64; +pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 512; const CYBER_VERIFY_URL: &str = "https://chatgpt.com/cyber"; const CYBER_SAFETY_URL: &str = "https://developers.openai.com/codex/concepts/cyber-safety"; @@ -523,11 +528,13 @@ pub(crate) struct Session { /// session. features: Features, pending_mcp_server_refresh_config: Mutex>, + pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, pub(crate) services: SessionServices, js_repl: Arc, next_internal_sub_id: AtomicU64, } + /// The context needed for a single turn of the thread. #[derive(Debug)] pub(crate) struct TurnContext { @@ -1357,6 +1364,7 @@ impl Session { state: Mutex::new(state), features: config.features.clone(), pending_mcp_server_refresh_config: Mutex::new(None), + conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), services, js_repl, @@ -1961,7 +1969,7 @@ impl Session { state.take_startup_regular_task() } - async fn get_config(&self) -> std::sync::Arc { + pub(crate) async fn get_config(&self) -> std::sync::Arc { let state = self.state.lock().await; state .session_configuration @@ -1969,6 +1977,11 @@ impl Session { .clone() } + pub(crate) async fn provider(&self) -> ModelProviderInfo { + let state = self.state.lock().await; + state.session_configuration.provider.clone() + } + pub(crate) async fn reload_user_config_layer(&self) { let config_toml_path = { let state = self.state.lock().await; @@ -3170,6 +3183,29 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::CleanBackgroundTerminals => { handlers::clean_background_terminals(&sess).await; } + Op::RealtimeConversationStart(params) => { + if let Err(err) = + handle_realtime_conversation_start(&sess, sub.id.clone(), params).await + { + sess.send_event_raw(Event { + id: sub.id.clone(), + msg: EventMsg::Error(ErrorEvent { + message: err.to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }) + .await; + } + } + Op::RealtimeConversationAudio(params) => { + handle_realtime_conversation_audio(&sess, sub.id.clone(), params).await; + } + Op::RealtimeConversationText(params) => { + handle_realtime_conversation_text(&sess, sub.id.clone(), params).await; + } + Op::RealtimeConversationClose => { + handle_realtime_conversation_close(&sess, sub.id.clone()).await; + } Op::OverrideTurnContext { cwd, approval_policy, @@ -4015,6 +4051,7 @@ mod handlers { pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + let _ = sess.conversation.shutdown().await; sess.services .unified_exec_manager .terminate_all_processes() @@ -7467,6 +7504,7 @@ mod tests { state: Mutex::new(state), features: config.features.clone(), pending_mcp_server_refresh_config: Mutex::new(None), + conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), services, js_repl, @@ -7623,6 +7661,7 @@ mod tests { state: Mutex::new(state), features: config.features.clone(), pending_mcp_server_refresh_config: Mutex::new(None), + conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), services, js_repl, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ccfd340dfe..4dc2a607c6 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -13,6 +13,7 @@ pub mod auth; mod client; mod client_common; pub mod codex; +mod realtime_conversation; pub use codex::SteerInputError; mod codex_thread; mod compact_remote; diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs new file mode 100644 index 0000000000..dfaddd97bd --- /dev/null +++ b/codex-rs/core/src/realtime_conversation.rs @@ -0,0 +1,354 @@ +use crate::CodexAuth; +use crate::api_bridge::map_api_error; +use crate::codex::Session; +use crate::default_client::default_headers; +use crate::error::CodexErr; +use crate::error::Result as CodexResult; +use async_channel::Receiver; +use async_channel::Sender; +use async_channel::TrySendError; +use codex_api::Provider as ApiProvider; +use codex_api::RealtimeAudioFrame; +use codex_api::RealtimeEvent; +use codex_api::RealtimeSessionConfig; +use codex_api::RealtimeWebsocketClient; +use codex_api::endpoint::realtime_websocket::RealtimeWebsocketEvents; +use codex_api::endpoint::realtime_websocket::RealtimeWebsocketWriter; +use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::ConversationAudioParams; +use codex_protocol::protocol::ConversationStartParams; +use codex_protocol::protocol::ConversationTextParams; +use codex_protocol::protocol::ErrorEvent; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::RealtimeConversationClosedEvent; +use codex_protocol::protocol::RealtimeConversationRealtimeEvent; +use codex_protocol::protocol::RealtimeConversationStartedEvent; +use http::HeaderMap; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tracing::error; +use tracing::warn; + +const AUDIO_IN_QUEUE_CAPACITY: usize = 256; +const TEXT_IN_QUEUE_CAPACITY: usize = 64; +const OUTPUT_EVENTS_QUEUE_CAPACITY: usize = 256; + +pub(crate) struct RealtimeConversationManager { + state: Mutex>, +} + +#[allow(dead_code)] +struct ConversationState { + audio_tx: Sender, + text_tx: Sender, + task: JoinHandle<()>, +} + +#[allow(dead_code)] +impl RealtimeConversationManager { + pub(crate) fn new() -> Self { + Self { + state: Mutex::new(None), + } + } + + pub(crate) async fn running_state(&self) -> Option<()> { + let state = self.state.lock().await; + state.as_ref().map(|_| ()) + } + + pub(crate) async fn start( + &self, + api_provider: ApiProvider, + extra_headers: Option, + prompt: String, + session_id: Option, + ) -> CodexResult> { + let previous_state = { + let mut guard = self.state.lock().await; + guard.take() + }; + if let Some(state) = previous_state { + state.task.abort(); + let _ = state.task.await; + } + + let session_config = RealtimeSessionConfig { prompt, session_id }; + let client = RealtimeWebsocketClient::new(api_provider); + let connection = client + .connect( + session_config, + extra_headers.unwrap_or_default(), + default_headers(), + ) + .await + .map_err(map_api_error)?; + + let writer = connection.writer(); + let events = connection.events(); + let (audio_tx, audio_rx) = + async_channel::bounded::(AUDIO_IN_QUEUE_CAPACITY); + let (text_tx, text_rx) = async_channel::bounded::(TEXT_IN_QUEUE_CAPACITY); + let (events_tx, events_rx) = + async_channel::bounded::(OUTPUT_EVENTS_QUEUE_CAPACITY); + + let task = spawn_realtime_input_task(writer, events, text_rx, audio_rx, events_tx); + + let mut guard = self.state.lock().await; + *guard = Some(ConversationState { + audio_tx, + text_tx, + task, + }); + Ok(events_rx) + } + + pub(crate) async fn audio_in(&self, frame: RealtimeAudioFrame) -> CodexResult<()> { + let sender = { + let guard = self.state.lock().await; + guard.as_ref().map(|state| state.audio_tx.clone()) + }; + + let Some(sender) = sender else { + return Err(CodexErr::InvalidRequest( + "conversation is not running".to_string(), + )); + }; + + match sender.try_send(frame) { + Ok(()) => Ok(()), + Err(TrySendError::Full(_)) => { + warn!("dropping input audio frame due to full queue"); + Ok(()) + } + Err(TrySendError::Closed(_)) => Err(CodexErr::InvalidRequest( + "conversation is not running".to_string(), + )), + } + } + + pub(crate) async fn text_in(&self, text: String) -> CodexResult<()> { + let sender = { + let guard = self.state.lock().await; + guard.as_ref().map(|state| state.text_tx.clone()) + }; + + let Some(sender) = sender else { + return Err(CodexErr::InvalidRequest( + "conversation is not running".to_string(), + )); + }; + + sender + .send(text) + .await + .map_err(|_| CodexErr::InvalidRequest("conversation is not running".to_string()))?; + Ok(()) + } + + pub(crate) async fn shutdown(&self) -> CodexResult<()> { + let state = { + let mut guard = self.state.lock().await; + guard.take() + }; + + if let Some(state) = state { + state.task.abort(); + let _ = state.task.await; + } + Ok(()) + } +} + +pub(crate) async fn handle_start( + sess: &Arc, + sub_id: String, + params: ConversationStartParams, +) -> CodexResult<()> { + let provider = sess.provider().await; + let auth = sess.services.auth_manager.auth().await; + let api_provider = provider.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?; + + let requested_session_id = params + .session_id + .or_else(|| Some(sess.conversation_id.to_string())); + let events_rx = match sess + .conversation + .start( + api_provider, + None, + params.prompt, + requested_session_id.clone(), + ) + .await + { + Ok(events_rx) => events_rx, + Err(err) => { + send_conversation_error(sess, sub_id, err.to_string(), CodexErrorInfo::Other).await; + return Ok(()); + } + }; + + sess.send_event_raw(Event { + id: sub_id.clone(), + msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent { + session_id: requested_session_id, + }), + }) + .await; + + let sess_clone = Arc::clone(sess); + tokio::spawn(async move { + let ev = |msg| Event { + id: sub_id.clone(), + msg, + }; + while let Ok(event) = events_rx.recv().await { + sess_clone + .send_event_raw(ev(EventMsg::RealtimeConversationRealtime( + RealtimeConversationRealtimeEvent { payload: event }, + ))) + .await; + } + if let Some(()) = sess_clone.conversation.running_state().await { + sess_clone + .send_event_raw(ev(EventMsg::RealtimeConversationClosed( + RealtimeConversationClosedEvent { + reason: Some("transport_closed".to_string()), + }, + ))) + .await; + } + }); + + Ok(()) +} + +pub(crate) async fn handle_audio( + sess: &Arc, + sub_id: String, + params: ConversationAudioParams, +) { + if let Err(err) = sess.conversation.audio_in(params.frame).await { + send_conversation_error(sess, sub_id, err.to_string(), CodexErrorInfo::BadRequest).await; + } +} + +pub(crate) async fn handle_text( + sess: &Arc, + sub_id: String, + params: ConversationTextParams, +) { + if let Err(err) = sess.conversation.text_in(params.text).await { + send_conversation_error(sess, sub_id, err.to_string(), CodexErrorInfo::BadRequest).await; + } +} + +pub(crate) async fn handle_close(sess: &Arc, sub_id: String) { + match sess.conversation.shutdown().await { + Ok(()) => { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::RealtimeConversationClosed(RealtimeConversationClosedEvent { + reason: Some("requested".to_string()), + }), + }) + .await; + } + Err(err) => { + send_conversation_error(sess, sub_id, err.to_string(), CodexErrorInfo::Other).await; + } + } +} + +fn spawn_realtime_input_task( + writer: RealtimeWebsocketWriter, + events: RealtimeWebsocketEvents, + text_rx: Receiver, + audio_rx: Receiver, + events_tx: Sender, +) -> JoinHandle<()> { + tokio::spawn(async move { + loop { + tokio::select! { + biased; + text = text_rx.recv() => { + match text { + Ok(text) => { + if let Err(err) = writer.send_conversation_item_create(text).await { + let mapped_error = map_api_error(err); + warn!("failed to send input text: {mapped_error}"); + break; + } + } + Err(_) => break, + } + } + event = events.next_event() => { + match event { + Ok(Some(event)) => { + let should_stop = matches!(&event, RealtimeEvent::Error(_)); + if events_tx.send(event).await.is_err() { + break; + } + if should_stop { + error!("realtime stream error event received"); + break; + } + } + Ok(None) => { + let _ = events_tx + .send(RealtimeEvent::Error( + "realtime websocket connection is closed".to_string(), + )) + .await; + break; + } + Err(err) => { + let mapped_error = map_api_error(err); + if events_tx + .send(RealtimeEvent::Error(mapped_error.to_string())) + .await + .is_err() + { + break; + } + error!("realtime stream closed: {mapped_error}"); + break; + } + } + } + frame = audio_rx.recv() => { + match frame { + Ok(frame) => { + if let Err(err) = writer.send_audio_frame(frame).await { + let mapped_error = map_api_error(err); + error!("failed to send input audio: {mapped_error}"); + break; + } + } + Err(_) => break, + } + } + } + } + }) +} + +async fn send_conversation_error( + sess: &Arc, + sub_id: String, + message: String, + codex_error_info: CodexErrorInfo, +) { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message, + codex_error_info: Some(codex_error_info), + }), + }) + .await; +} diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index f83433efb2..7899f0e83d 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -122,6 +122,9 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::CollabCloseEnd(_) | EventMsg::CollabResumeEnd(_) => Some(EventPersistenceMode::Extended), EventMsg::Warning(_) + | EventMsg::RealtimeConversationStarted(_) + | EventMsg::RealtimeConversationRealtime(_) + | EventMsg::RealtimeConversationClosed(_) | EventMsg::ModelReroute(_) | EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 5e3d657996..7d73e46683 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -96,6 +96,7 @@ mod personality_migration; mod prompt_caching; mod quota_exceeded; mod read_file; +mod realtime_conversation; mod remote_models; mod request_compression; mod request_user_input; diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs new file mode 100644 index 0000000000..a6eccc969b --- /dev/null +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -0,0 +1,360 @@ +use anyhow::Result; +use codex_core::protocol::CodexErrorInfo; +use codex_core::protocol::ConversationAudioParams; +use codex_core::protocol::ConversationStartParams; +use codex_core::protocol::ConversationTextParams; +use codex_core::protocol::ErrorEvent; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::RealtimeAudioFrame; +use codex_core::protocol::RealtimeConversationRealtimeEvent; +use codex_core::protocol::RealtimeEvent; +use core_test_support::responses::start_websocket_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::time::Duration; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_start_audio_text_close_round_trip() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![ + vec![], + vec![ + vec![json!({ + "type": "session.created", + "session": { "id": "sess_1" } + })], + vec![], + vec![ + json!({ + "type": "response.output_audio.delta", + "delta": "AQID", + "sample_rate": 24000, + "num_channels": 1 + }), + json!({ + "type": "conversation.item.added", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "hi"}] + } + }), + ], + ], + ]) + .await; + + let mut builder = test_codex(); + let test = builder.build_with_websocket_server(&server).await?; + assert!(server.wait_for_handshakes(1, Duration::from_secs(2)).await); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let started = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationStarted(started) => Some(Ok(started.clone())), + EventMsg::Error(err) => Some(Err(err.clone())), + _ => None, + }) + .await + .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); + assert!(started.session_id.is_some()); + + let session_created = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) => Some(session_id.clone()), + _ => None, + }) + .await; + assert_eq!(session_created, "sess_1"); + + test.codex + .submit(Op::RealtimeConversationAudio(ConversationAudioParams { + frame: RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 24000, + num_channels: 1, + samples_per_channel: Some(480), + }, + })) + .await?; + test.codex + .submit(Op::RealtimeConversationText(ConversationTextParams { + text: "hello".to_string(), + })) + .await?; + + let audio_out = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::AudioOut(frame), + }) => Some(frame.clone()), + _ => None, + }) + .await; + assert_eq!(audio_out.data, "AQID"); + + let connections = server.connections(); + assert_eq!(connections.len(), 2); + let connection = &connections[1]; + assert_eq!(connection.len(), 3); + assert_eq!( + connection[0].body_json()["type"].as_str(), + Some("session.create") + ); + assert_eq!( + connection[0].body_json()["session"]["conversation_id"] + .as_str() + .expect("session.create conversation_id"), + started + .session_id + .as_deref() + .expect("started session id should be present") + ); + let request_types = [ + connection[1].body_json()["type"] + .as_str() + .expect("request type") + .to_string(), + connection[2].body_json()["type"] + .as_str() + .expect("request type") + .to_string(), + ]; + assert_eq!( + request_types, + [ + "conversation.item.create".to_string(), + "response.input_audio.delta".to_string(), + ] + ); + + test.codex.submit(Op::RealtimeConversationClose).await?; + let closed = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()), + _ => None, + }) + .await; + assert!(matches!( + closed.reason.as_deref(), + Some("requested" | "transport_closed") + )); + + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_transport_close_emits_closed_event() -> Result<()> { + skip_if_no_network!(Ok(())); + + let session_created = vec![json!({ + "type": "session.created", + "session": { "id": "sess_1" } + })]; + let server = start_websocket_server(vec![vec![], vec![session_created]]).await; + + let mut builder = test_codex(); + let test = builder.build_with_websocket_server(&server).await?; + assert!(server.wait_for_handshakes(1, Duration::from_secs(2)).await); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let started = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationStarted(started) => Some(Ok(started.clone())), + EventMsg::Error(err) => Some(Err(err.clone())), + _ => None, + }) + .await + .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); + assert!(started.session_id.is_some()); + + let session_created = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) => Some(session_id.clone()), + _ => None, + }) + .await; + assert_eq!(session_created, "sess_1"); + + let closed = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()), + _ => None, + }) + .await; + assert_eq!(closed.reason.as_deref(), Some("transport_closed")); + + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_audio_before_start_emits_error() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![]).await; + let mut builder = test_codex(); + let test = builder.build_with_websocket_server(&server).await?; + + test.codex + .submit(Op::RealtimeConversationAudio(ConversationAudioParams { + frame: RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 24000, + num_channels: 1, + samples_per_channel: Some(480), + }, + })) + .await?; + + let err = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::Error(err) => Some(err.clone()), + _ => None, + }) + .await; + assert_eq!(err.codex_error_info, Some(CodexErrorInfo::BadRequest)); + assert_eq!(err.message, "conversation is not running"); + + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_text_before_start_emits_error() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![]).await; + let mut builder = test_codex(); + let test = builder.build_with_websocket_server(&server).await?; + + test.codex + .submit(Op::RealtimeConversationText(ConversationTextParams { + text: "hello".to_string(), + })) + .await?; + + let err = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::Error(err) => Some(err.clone()), + _ => None, + }) + .await; + assert_eq!(err.codex_error_info, Some(CodexErrorInfo::BadRequest)); + assert_eq!(err.message, "conversation is not running"); + + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_second_start_replaces_runtime() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![ + vec![], + vec![vec![json!({ + "type": "session.created", + "session": { "id": "sess_old" } + })]], + vec![ + vec![json!({ + "type": "session.created", + "session": { "id": "sess_new" } + })], + vec![json!({ + "type": "response.output_audio.delta", + "delta": "AQID", + "sample_rate": 24000, + "num_channels": 1 + })], + ], + ]) + .await; + let mut builder = test_codex(); + let test = builder.build_with_websocket_server(&server).await?; + assert!(server.wait_for_handshakes(1, Duration::from_secs(2)).await); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "old".to_string(), + session_id: Some("conv_old".to_string()), + })) + .await?; + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) if session_id == "sess_old" => Some(Ok(())), + EventMsg::Error(err) => Some(Err(err.clone())), + _ => None, + }) + .await + .unwrap_or_else(|err: ErrorEvent| panic!("first conversation start failed: {err:?}")); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "new".to_string(), + session_id: Some("conv_new".to_string()), + })) + .await?; + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) if session_id == "sess_new" => Some(Ok(())), + EventMsg::Error(err) => Some(Err(err.clone())), + _ => None, + }) + .await + .unwrap_or_else(|err: ErrorEvent| panic!("second conversation start failed: {err:?}")); + + test.codex + .submit(Op::RealtimeConversationAudio(ConversationAudioParams { + frame: RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 24000, + num_channels: 1, + samples_per_channel: Some(480), + }, + })) + .await?; + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::AudioOut(frame), + }) if frame.data == "AQID" => Some(()), + _ => None, + }) + .await; + + let connections = server.connections(); + assert_eq!(connections.len(), 3); + assert_eq!(connections[1].len(), 1); + assert_eq!( + connections[1][0].body_json()["session"]["conversation_id"].as_str(), + Some("conv_old") + ); + assert_eq!(connections[2].len(), 2); + assert_eq!( + connections[2][0].body_json()["session"]["conversation_id"].as_str(), + Some("conv_new") + ); + assert_eq!( + connections[2][1].body_json()["type"].as_str(), + Some("response.input_audio.delta") + ); + + server.shutdown().await; + Ok(()) +} diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 6583c19f9e..1e2a1b9e6d 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -806,6 +806,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::RequestUserInput(_) | EventMsg::CollabResumeBegin(_) | EventMsg::CollabResumeEnd(_) + | EventMsg::RealtimeConversationStarted(_) + | EventMsg::RealtimeConversationRealtime(_) + | EventMsg::RealtimeConversationClosed(_) | EventMsg::DynamicToolCallRequest(_) => {} } CodexStatus::Running diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index c20c877c41..762052ccf4 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -374,6 +374,9 @@ async fn run_codex_tool_session_inner( | EventMsg::CollabCloseEnd(_) | EventMsg::CollabResumeBegin(_) | EventMsg::CollabResumeEnd(_) + | EventMsg::RealtimeConversationStarted(_) + | EventMsg::RealtimeConversationRealtime(_) + | EventMsg::RealtimeConversationClosed(_) | EventMsg::DeprecationNotice(_) => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5b3d41888c..86a1248657 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -85,6 +85,41 @@ pub struct McpServerRefreshConfig { pub mcp_oauth_credentials_store_mode: Value, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct ConversationStartParams { + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RealtimeAudioFrame { + pub data: String, + pub sample_rate: u32, + pub num_channels: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub samples_per_channel: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub enum RealtimeEvent { + SessionCreated { session_id: String }, + SessionUpdated { backend_prompt: Option }, + AudioOut(RealtimeAudioFrame), + ConversationItemAdded(Value), + Error(String), +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct ConversationAudioParams { + pub frame: RealtimeAudioFrame, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct ConversationTextParams { + pub text: String, +} + /// Submission operation #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] @@ -98,6 +133,18 @@ pub enum Op { /// Terminate all running background terminal processes for this thread. CleanBackgroundTerminals, + /// Start a realtime conversation stream. + RealtimeConversationStart(ConversationStartParams), + + /// Send audio input to the running realtime conversation stream. + RealtimeConversationAudio(ConversationAudioParams), + + /// Send text input to the running realtime conversation stream. + RealtimeConversationText(ConversationTextParams), + + /// Close the running realtime conversation stream. + RealtimeConversationClose, + /// Legacy user input. /// /// Prefer [`Op::UserTurn`] so the caller provides full turn context @@ -899,6 +946,15 @@ pub enum EventMsg { /// indicates the turn continued but the user should still be notified. Warning(WarningEvent), + /// Realtime conversation lifecycle start event. + RealtimeConversationStarted(RealtimeConversationStartedEvent), + + /// Realtime conversation streaming payload event. + RealtimeConversationRealtime(RealtimeConversationRealtimeEvent), + + /// Realtime conversation lifecycle close event. + RealtimeConversationClosed(RealtimeConversationClosedEvent), + /// Model routing changed from the requested model to a different model. ModelReroute(ModelRerouteEvent), @@ -1078,6 +1134,22 @@ pub enum EventMsg { CollabResumeEnd(CollabResumeEndEvent), } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct RealtimeConversationStartedEvent { + pub session_id: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct RealtimeConversationRealtimeEvent { + pub payload: RealtimeEvent, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct RealtimeConversationClosedEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + impl From for EventMsg { fn from(event: CollabAgentSpawnBeginEvent) -> Self { EventMsg::CollabAgentSpawnBegin(event) @@ -3047,6 +3119,61 @@ mod tests { assert!(event.affects_turn_status()); } + #[test] + fn conversation_op_serializes_as_unnested_variants() { + let audio = Op::RealtimeConversationAudio(ConversationAudioParams { + frame: RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: Some(480), + }, + }); + let start = Op::RealtimeConversationStart(ConversationStartParams { + prompt: "be helpful".to_string(), + session_id: Some("conv_1".to_string()), + }); + let text = Op::RealtimeConversationText(ConversationTextParams { + text: "hello".to_string(), + }); + let close = Op::RealtimeConversationClose; + + assert_eq!( + serde_json::to_value(&start).unwrap(), + json!({ + "type": "realtime_conversation_start", + "prompt": "be helpful", + "session_id": "conv_1" + }) + ); + assert_eq!( + serde_json::to_value(&audio).unwrap(), + json!({ + "type": "realtime_conversation_audio", + "frame": { + "data": "AQID", + "sample_rate": 24000, + "num_channels": 1, + "samples_per_channel": 480 + } + }) + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&text).unwrap()).unwrap(), + text + ); + assert_eq!( + serde_json::to_value(&close).unwrap(), + json!({ + "type": "realtime_conversation_close" + }) + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&close).unwrap()).unwrap(), + close + ); + } + #[test] fn user_input_serialization_omits_final_output_json_schema_when_none() -> Result<()> { let op = Op::UserInput { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index afc894b22b..8850cc8b73 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4186,6 +4186,9 @@ impl ChatWidget { | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::RealtimeConversationStarted(_) + | EventMsg::RealtimeConversationRealtime(_) + | EventMsg::RealtimeConversationClosed(_) | EventMsg::DynamicToolCallRequest(_) => {} EventMsg::ItemCompleted(event) => { let item = event.item; From 0644ba7b7e00dfe308794b3bb3adff6ec717707d Mon Sep 17 00:00:00 2001 From: Rohan Godha Date: Fri, 20 Feb 2026 22:32:15 -0500 Subject: [PATCH 019/282] fix(nix): include libcap dependency on linux builds (#12415) commit 923f9311215df350248e4a314e8931bfb9033bcd introduced a dependency on `libcap`. This PR fixes the nix build by including `libcap` in nix's build inputs issue number: #12102. @etraut-openai gave me permission to open pr Testing: running `nix run .#codex-rs` works on both macos (aarch64) and nixos (x86-64) --- codex-rs/default.nix | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/codex-rs/default.nix b/codex-rs/default.nix index fc7dd978ef..80896689f9 100644 --- a/codex-rs/default.nix +++ b/codex-rs/default.nix @@ -2,16 +2,18 @@ cmake, llvmPackages, openssl, + libcap ? null, rustPlatform, pkg-config, lib, + stdenv, version ? "0.0.0", ... }: rustPlatform.buildRustPackage (_: { - env = { - PKG_CONFIG_PATH = "${openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH"; - }; + env.PKG_CONFIG_PATH = lib.makeSearchPathOutput "dev" "lib/pkgconfig" ( + [ openssl ] ++ lib.optionals stdenv.isLinux [ libcap ] + ); pname = "codex-rs"; inherit version; cargoLock.lockFile = ./Cargo.lock; @@ -31,6 +33,8 @@ rustPlatform.buildRustPackage (_: { llvmPackages.libclang.lib openssl pkg-config + ] ++ lib.optionals stdenv.isLinux [ + libcap ]; cargoLock.outputHashes = { @@ -47,5 +51,6 @@ rustPlatform.buildRustPackage (_: { description = "OpenAI Codex command‑line interface rust implementation"; license = licenses.asl20; homepage = "https://github.com/openai/codex"; + mainProgram = "codex"; }; }) From 7ae5d8801655fe3ecc66d0f14442e45b0bfe196f Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 20 Feb 2026 19:51:20 -0800 Subject: [PATCH 020/282] Add experimental realtime websocket URL override (#12416) - add top-level `experimental_realtime_ws_base_url` config key (experimental / do not use) and include it in config schema - apply the override only to `Op::RealtimeConversation` websocket transport, with config + realtime tests --- codex-rs/core/config.schema.json | 4 ++ codex-rs/core/src/config/mod.rs | 43 +++++++++++++++ codex-rs/core/src/realtime_conversation.rs | 6 +- .../core/tests/suite/realtime_conversation.rs | 55 +++++++++++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 4de4da6e48..9096455fae 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1541,6 +1541,10 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, + "experimental_realtime_ws_base_url": { + "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport base URL (the `Op::RealtimeConversation` `/ws` connection) without changing normal provider HTTP requests.", + "type": "string" + }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 76ba2fcb2d..68bc623e0a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -394,6 +394,11 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport base URL (the `Op::RealtimeConversation` `/ws` + /// connection) without changing normal provider HTTP requests. + pub experimental_realtime_ws_base_url: Option, + /// When set, restricts ChatGPT login to a specific workspace identifier. pub forced_chatgpt_workspace_id: Option, @@ -1119,6 +1124,11 @@ pub struct ConfigToml { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport base URL (the `Op::RealtimeConversation` `/ws` + /// connection) without changing normal provider HTTP requests. + pub experimental_realtime_ws_base_url: Option, + pub projects: Option>, /// Controls the web search tool mode: disabled, cached, or live. @@ -2043,6 +2053,7 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + experimental_realtime_ws_base_url: cfg.experimental_realtime_ws_base_url, forced_chatgpt_workspace_id, forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, @@ -4583,6 +4594,7 @@ model_verbosity = "high" model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_realtime_ws_base_url: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -4702,6 +4714,7 @@ model_verbosity = "high" model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_realtime_ws_base_url: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -4819,6 +4832,7 @@ model_verbosity = "high" model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_realtime_ws_base_url: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -4922,6 +4936,7 @@ model_verbosity = "high" model_verbosity: Some(Verbosity::High), personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_realtime_ws_base_url: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -5708,6 +5723,34 @@ trust_level = "untrusted" ); Ok(()) } + + #[test] + fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_realtime_ws_base_url = "http://127.0.0.1:8011" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_realtime_ws_base_url.as_deref(), + Some("http://127.0.0.1:8011") + ); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.experimental_realtime_ws_base_url.as_deref(), + Some("http://127.0.0.1:8011") + ); + Ok(()) + } } #[cfg(test)] diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index dfaddd97bd..b41b4bd0d4 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -169,7 +169,11 @@ pub(crate) async fn handle_start( ) -> CodexResult<()> { let provider = sess.provider().await; let auth = sess.services.auth_manager.auth().await; - let api_provider = provider.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?; + let mut api_provider = provider.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?; + let config = sess.get_config().await; + if let Some(realtime_ws_base_url) = &config.experimental_realtime_ws_base_url { + api_provider.base_url = realtime_ws_base_url.clone(); + } let requested_session_id = params .session_id diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index a6eccc969b..ddc63385de 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -358,3 +358,58 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { server.shutdown().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Result<()> { + skip_if_no_network!(Ok(())); + + let startup_server = start_websocket_server(vec![vec![]]).await; + let realtime_server = start_websocket_server(vec![vec![vec![json!({ + "type": "session.created", + "session": { "id": "sess_override" } + })]]]) + .await; + + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_websocket_server(&startup_server).await?; + assert!( + startup_server + .wait_for_handshakes(1, Duration::from_secs(2)) + .await + ); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let session_created = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) => Some(session_id.clone()), + _ => None, + }) + .await; + assert_eq!(session_created, "sess_override"); + + let startup_connections = startup_server.connections(); + assert_eq!(startup_connections.len(), 1); + + let realtime_connections = realtime_server.connections(); + assert_eq!(realtime_connections.len(), 1); + assert_eq!( + realtime_connections[0][0].body_json()["type"].as_str(), + Some("session.create") + ); + + startup_server.shutdown().await; + realtime_server.shutdown().await; + Ok(()) +} From 4c1744afb22383ae61bebf7feb19a24eedc970df Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Fri, 20 Feb 2026 20:08:56 -0800 Subject: [PATCH 021/282] Improve Plan mode reasoning selection flow (#12303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses https://github.com/openai/codex/issues/11013 ## Summary - add a Plan implementation path in the TUI that lets users choose reasoning before switching to Default mode and implementing - add Plan-mode reasoning scope handling (Plan-only override vs all-modes default), including config/schema/docs plumbing for `plan_mode_reasoning_effort` - remove the hardcoded Plan preset medium default and make the reasoning popup reflect the active Plan override as `(current)` - split the collaboration-mode switch notification UI hint into #12307 to keep this diff focused If I have `plan_mode_reasoning_effort = "medium"` set in my `config.toml`: Screenshot 2026-02-20 at 6 59 37 PM If I don't have `plan_mode_reasoning_effort` set in my `config.toml`: Screenshot 2026-02-20 at 7 01 51 PM ## Codex author `codex resume 019c78a2-726b-7fe3-adac-3fa4523dcc2a` --- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/config/mod.rs | 15 + codex-rs/core/src/config/profile.rs | 1 + .../collaboration_mode_presets.rs | 4 + codex-rs/tui/src/app.rs | 52 ++++ codex-rs/tui/src/app_event.rs | 12 + codex-rs/tui/src/chatwidget.rs | 252 +++++++++++++---- ...ng_selection_popup_extra_high_warning.snap | 1 - codex-rs/tui/src/chatwidget/tests.rs | 261 +++++++++++++++++- docs/config.md | 9 + 10 files changed, 558 insertions(+), 55 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9096455fae..cda5b8eaba 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -467,6 +467,9 @@ "personality": { "$ref": "#/definitions/Personality" }, + "plan_mode_reasoning_effort": { + "$ref": "#/definitions/ReasoningEffort" + }, "sandbox_mode": { "$ref": "#/definitions/SandboxMode" }, @@ -1901,6 +1904,9 @@ ], "description": "Optionally specify a personality for the model" }, + "plan_mode_reasoning_effort": { + "$ref": "#/definitions/ReasoningEffort" + }, "profile": { "description": "Profile to use from the `profiles` map.", "type": "string" diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 68bc623e0a..168633194e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -376,6 +376,13 @@ pub struct Config { /// Value to use for `reasoning.effort` when making a request using the /// Responses API. pub model_reasoning_effort: Option, + /// Optional Plan-mode-specific reasoning effort override used by the TUI. + /// + /// When unset, Plan mode uses the built-in Plan preset default (currently + /// `medium`). When explicitly set (including `none`), this overrides the + /// Plan preset. The `none` value means "no reasoning" (not "inherit the + /// global default"). + pub plan_mode_reasoning_effort: Option, /// If not "none", the value to use for `reasoning.summary` when making a /// request using the Responses API. @@ -1107,6 +1114,7 @@ pub struct ConfigToml { pub show_raw_agent_reasoning: Option, pub model_reasoning_effort: Option, + pub plan_mode_reasoning_effort: Option, pub model_reasoning_summary: Option, /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`). pub model_verbosity: Option, @@ -2042,6 +2050,9 @@ impl Config { model_reasoning_effort: config_profile .model_reasoning_effort .or(cfg.model_reasoning_effort), + plan_mode_reasoning_effort: config_profile + .plan_mode_reasoning_effort + .or(cfg.plan_mode_reasoning_effort), model_reasoning_summary: config_profile .model_reasoning_summary .or(cfg.model_reasoning_summary) @@ -4588,6 +4599,7 @@ model_verbosity = "high" hide_agent_reasoning: false, show_raw_agent_reasoning: false, model_reasoning_effort: Some(ReasoningEffort::High), + plan_mode_reasoning_effort: None, model_reasoning_summary: ReasoningSummary::Detailed, model_supports_reasoning_summaries: None, model_catalog: None, @@ -4708,6 +4720,7 @@ model_verbosity = "high" hide_agent_reasoning: false, show_raw_agent_reasoning: false, model_reasoning_effort: None, + plan_mode_reasoning_effort: None, model_reasoning_summary: ReasoningSummary::default(), model_supports_reasoning_summaries: None, model_catalog: None, @@ -4826,6 +4839,7 @@ model_verbosity = "high" hide_agent_reasoning: false, show_raw_agent_reasoning: false, model_reasoning_effort: None, + plan_mode_reasoning_effort: None, model_reasoning_summary: ReasoningSummary::default(), model_supports_reasoning_summaries: None, model_catalog: None, @@ -4930,6 +4944,7 @@ model_verbosity = "high" hide_agent_reasoning: false, show_raw_agent_reasoning: false, model_reasoning_effort: Some(ReasoningEffort::High), + plan_mode_reasoning_effort: None, model_reasoning_summary: ReasoningSummary::Detailed, model_supports_reasoning_summaries: None, model_catalog: None, diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index d8b7ab1baf..3c7d575cdc 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -24,6 +24,7 @@ pub struct ConfigProfile { pub approval_policy: Option, pub sandbox_mode: Option, pub model_reasoning_effort: Option, + pub plan_mode_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, pub personality: Option, diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs index 25f083cb81..7b900609b3 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -75,6 +75,10 @@ mod tests { fn preset_names_use_mode_display_names() { assert_eq!(plan_preset().name, ModeKind::Plan.display_name()); assert_eq!(default_preset().name, ModeKind::Default.display_name()); + assert_eq!( + plan_preset().reasoning_effort, + Some(Some(ReasoningEffort::Medium)) + ); } #[test] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5898059644..0090ef5e9b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1764,6 +1764,10 @@ impl App { AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } + AppEvent::OpenPlanReasoningScopePrompt { model, effort } => { + self.chat_widget + .open_plan_reasoning_scope_prompt(model, effort); + } AppEvent::OpenAllModelsPopup { models } => { self.chat_widget.open_all_models_popup(models); } @@ -2115,6 +2119,10 @@ impl App { .await { Ok(()) => { + let effort_label = effort + .map(|selected_effort| selected_effort.to_string()) + .unwrap_or_else(|| "default".to_string()); + tracing::info!("Selected model: {model}, Selected effort: {effort_label}"); let mut message = format!("Model changed to {model}"); if let Some(label) = Self::reasoning_label_for(&model, effort) { message.push(' '); @@ -2315,6 +2323,11 @@ impl App { AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => { self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); } + AppEvent::UpdatePlanModeReasoningEffort(effort) => { + self.config.plan_mode_reasoning_effort = effort; + self.chat_widget.set_plan_mode_reasoning_effort(effort); + self.refresh_status_line(); + } AppEvent::PersistFullAccessWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .set_hide_full_access_warning(true) @@ -2360,6 +2373,45 @@ impl App { )); } } + AppEvent::PersistPlanModeReasoningEffort(effort) => { + let profile = self.active_profile.as_deref(); + let segments = if let Some(profile) = profile { + vec![ + "profiles".to_string(), + profile.to_string(), + "plan_mode_reasoning_effort".to_string(), + ] + } else { + vec!["plan_mode_reasoning_effort".to_string()] + }; + let edit = if let Some(effort) = effort { + ConfigEdit::SetPath { + segments, + value: effort.to_string().into(), + } + } else { + ConfigEdit::ClearPath { segments } + }; + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist plan mode reasoning effort" + ); + if let Some(profile) = profile { + self.chat_widget.add_error_message(format!( + "Failed to save Plan mode reasoning effort for profile `{profile}`: {err}" + )); + } else { + self.chat_widget.add_error_message(format!( + "Failed to save Plan mode reasoning effort: {err}" + )); + } + } + } AppEvent::PersistModelMigrationPromptAcknowledged { from_model, to_model, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1c9e8ce88e..17ce9f14b4 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -164,6 +164,12 @@ pub(crate) enum AppEvent { model: ModelPreset, }, + /// Open the Plan-mode reasoning scope prompt for the selected model/effort. + OpenPlanReasoningScopePrompt { + model: String, + effort: Option, + }, + /// Open the full model picker (non-auto models). OpenAllModelsPopup { models: Vec, @@ -258,6 +264,9 @@ pub(crate) enum AppEvent { /// Update whether the rate limit switch prompt has been acknowledged for the session. UpdateRateLimitSwitchPromptHidden(bool), + /// Update the Plan-mode-specific reasoning effort in memory. + UpdatePlanModeReasoningEffort(Option), + /// Persist the acknowledgement flag for the full access warning prompt. PersistFullAccessWarningAcknowledged, @@ -268,6 +277,9 @@ pub(crate) enum AppEvent { /// Persist the acknowledgement flag for the rate limit switch prompt. PersistRateLimitSwitchPromptHidden, + /// Persist the Plan-mode-specific reasoning effort. + PersistPlanModeReasoningEffort(Option), + /// Persist the acknowledgement flag for the model migration prompt. PersistModelMigrationPromptAcknowledged { from_model: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8850cc8b73..1cd3b3e14f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -158,6 +158,9 @@ const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?"; const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan"; const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; +const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change"; +const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override"; +const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override"; const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; /// Choose the keybinding used to edit the most-recently queued message. @@ -1452,7 +1455,6 @@ impl ChatWidget { } None => (Vec::new(), Some("Default mode unavailable".to_string())), }; - let items = vec![ SelectionItem { name: PLAN_IMPLEMENTATION_YES.to_string(), @@ -5031,16 +5033,20 @@ impl ChatWidget { } auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model)); - let mut items: Vec = auto_presets .into_iter() .map(|preset| { let description = (!preset.description.is_empty()).then_some(preset.description.clone()); let model = preset.model.clone(); + let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( + model.as_str(), + Some(preset.default_reasoning_effort), + ); let actions = Self::model_selection_actions( model.clone(), Some(preset.default_reasoning_effort), + should_prompt_plan_mode_scope, ); SelectionItem { name: model.clone(), @@ -5195,40 +5201,135 @@ impl ChatWidget { fn model_selection_actions( model_for_action: String, effort_for_action: Option, + should_prompt_plan_mode_scope: bool, ) -> Vec { vec![Box::new(move |tx| { - let effort_label = effort_for_action - .map(|effort| effort.to_string()) - .unwrap_or_else(|| "default".to_string()); - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - windows_sandbox_level: None, - model: Some(model_for_action.clone()), - effort: Some(effort_for_action), - summary: None, - collaboration_mode: None, - personality: None, - })); + if should_prompt_plan_mode_scope { + tx.send(AppEvent::OpenPlanReasoningScopePrompt { + model: model_for_action.clone(), + effort: effort_for_action, + }); + return; + } + tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), effort: effort_for_action, }); - tracing::info!( - "Selected model: {}, Selected effort: {}", - model_for_action, - effort_label - ); })] } + fn should_prompt_plan_mode_reasoning_scope( + &self, + selected_model: &str, + selected_effort: Option, + ) -> bool { + if !self.collaboration_modes_enabled() + || self.active_mode_kind() != ModeKind::Plan + || selected_model != self.current_model() + { + return false; + } + + // Prompt whenever the selection is not a true no-op for both: + // 1) the active Plan-mode effective reasoning, and + // 2) the stored global defaults that would be updated by the fallback path. + selected_effort != self.effective_reasoning_effort() + || selected_model != self.current_collaboration_mode.model() + || selected_effort != self.current_collaboration_mode.reasoning_effort() + } + + pub(crate) fn open_plan_reasoning_scope_prompt( + &mut self, + model: String, + effort: Option, + ) { + let reasoning_phrase = match effort { + Some(ReasoningEffortConfig::None) => "no reasoning".to_string(), + Some(selected_effort) => { + format!( + "{} reasoning", + Self::reasoning_effort_label(selected_effort).to_lowercase() + ) + } + None => "the selected reasoning".to_string(), + }; + let plan_only_description = format!("Always use {reasoning_phrase} in Plan mode."); + let plan_reasoning_source = if let Some(plan_override) = + self.config.plan_mode_reasoning_effort + { + format!( + "user-chosen Plan override ({})", + Self::reasoning_effort_label(plan_override).to_lowercase() + ) + } else if let Some(plan_mask) = collaboration_modes::plan_mask(self.models_manager.as_ref()) + { + match plan_mask.reasoning_effort.flatten() { + Some(plan_effort) => format!( + "built-in Plan default ({})", + Self::reasoning_effort_label(plan_effort).to_lowercase() + ), + None => "built-in Plan default (no reasoning)".to_string(), + } + } else { + "built-in Plan default".to_string() + }; + let all_modes_description = format!( + "Set the global default reasoning level and the Plan mode override. This replaces the current {plan_reasoning_source}." + ); + let subtitle = format!("Choose where to apply {reasoning_phrase}."); + + let plan_only_actions: Vec = vec![Box::new({ + let model = model.clone(); + move |tx| { + tx.send(AppEvent::UpdateModel(model.clone())); + tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); + tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + } + })]; + let all_modes_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateModel(model.clone())); + tx.send(AppEvent::UpdateReasoningEffort(effort)); + tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); + tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + tx.send(AppEvent::PersistModelSelection { + model: model.clone(), + effort, + }); + })]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some(PLAN_MODE_REASONING_SCOPE_TITLE.to_string()), + subtitle: Some(subtitle), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: PLAN_MODE_REASONING_SCOPE_PLAN_ONLY.to_string(), + description: Some(plan_only_description), + actions: plan_only_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: PLAN_MODE_REASONING_SCOPE_ALL_MODES.to_string(), + description: Some(all_modes_description), + actions: all_modes_actions, + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }); + } + /// Open a popup to choose the reasoning effort (stage 2) for the given model. pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; let supported = preset.supported_reasoning_efforts; + let in_plan_mode = + self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan; let warn_effort = if supported .iter() @@ -5272,10 +5373,16 @@ impl ChatWidget { } if choices.len() == 1 { - if let Some(effort) = choices.first().and_then(|c| c.stored) { - self.apply_model_and_effort(preset.model, Some(effort)); + let selected_effort = choices.first().and_then(|c| c.stored); + let selected_model = preset.model; + if self.should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort) { + self.app_event_tx + .send(AppEvent::OpenPlanReasoningScopePrompt { + model: selected_model, + effort: selected_effort, + }); } else { - self.apply_model_and_effort(preset.model, None); + self.apply_model_and_effort(selected_model, selected_effort); } return; } @@ -5291,7 +5398,13 @@ impl ChatWidget { let model_slug = preset.model.to_string(); let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { - self.effective_reasoning_effort() + if in_plan_mode { + self.config + .plan_mode_reasoning_effort + .or(self.effective_reasoning_effort()) + } else { + self.effective_reasoning_effort() + } } else { default_choice }; @@ -5334,7 +5447,24 @@ impl ChatWidget { }; let model_for_action = model_slug.clone(); - let actions = Self::model_selection_actions(model_for_action, choice.stored); + let choice_effort = choice.stored; + let should_prompt_plan_mode_scope = + self.should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), choice_effort); + let actions: Vec = vec![Box::new(move |tx| { + if should_prompt_plan_mode_scope { + tx.send(AppEvent::OpenPlanReasoningScopePrompt { + model: model_for_action.clone(), + effort: choice_effort, + }); + } else { + tx.send(AppEvent::UpdateModel(model_for_action.clone())); + tx.send(AppEvent::UpdateReasoningEffort(choice_effort)); + tx.send(AppEvent::PersistModelSelection { + model: model_for_action.clone(), + effort: choice_effort, + }); + } + })]; items.push(SelectionItem { name: effort_label, @@ -5372,33 +5502,20 @@ impl ChatWidget { } } - fn apply_model_and_effort(&self, model: String, effort: Option) { - self.app_event_tx - .send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - windows_sandbox_level: None, - model: Some(model.clone()), - effort: Some(effort), - summary: None, - collaboration_mode: None, - personality: None, - })); - self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); + fn apply_model_and_effort_without_persist( + &self, + model: String, + effort: Option, + ) { + self.app_event_tx.send(AppEvent::UpdateModel(model)); self.app_event_tx .send(AppEvent::UpdateReasoningEffort(effort)); - self.app_event_tx.send(AppEvent::PersistModelSelection { - model: model.clone(), - effort, - }); - tracing::info!( - "Selected model: {}, Selected effort: {}", - model, - effort - .map(|e| e.to_string()) - .unwrap_or_else(|| "default".to_string()) - ); + } + + fn apply_model_and_effort(&self, model: String, effort: Option) { + self.apply_model_and_effort_without_persist(model.clone(), effort); + self.app_event_tx + .send(AppEvent::PersistModelSelection { model, effort }); } /// Open the permissions popup (alias for /permissions). @@ -6163,6 +6280,22 @@ impl ChatWidget { .unwrap_or(false) } + pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option) { + self.config.plan_mode_reasoning_effort = effort; + if self.collaboration_modes_enabled() + && let Some(mask) = self.active_collaboration_mask.as_mut() + && mask.mode == Some(ModeKind::Plan) + { + if let Some(effort) = effort { + mask.reasoning_effort = Some(Some(effort)); + } else if let Some(plan_mask) = + collaboration_modes::plan_mask(self.models_manager.as_ref()) + { + mask.reasoning_effort = plan_mask.reasoning_effort; + } + } + } + /// Set the reasoning effort in the stored collaboration mode. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.current_collaboration_mode = @@ -6170,7 +6303,10 @@ impl ChatWidget { .with_updates(None, Some(effort), None); if self.collaboration_modes_enabled() && let Some(mask) = self.active_collaboration_mask.as_mut() + && mask.mode != Some(ModeKind::Plan) { + // Generic "global default" updates should not mutate the active Plan mask. + // Plan reasoning is controlled by the Plan preset and Plan-only override updates. mask.reasoning_effort = Some(effort); } } @@ -6394,13 +6530,18 @@ impl ChatWidget { /// /// When collaboration modes are enabled and a preset is selected, /// the current mode is attached to submissions as `Op::UserTurn { collaboration_mode: Some(...) }`. - pub(crate) fn set_collaboration_mask(&mut self, mask: CollaborationModeMask) { + pub(crate) fn set_collaboration_mask(&mut self, mut mask: CollaborationModeMask) { if !self.collaboration_modes_enabled() { return; } let previous_mode = self.active_mode_kind(); let previous_model = self.current_model().to_string(); let previous_effort = self.effective_reasoning_effort(); + if mask.mode == Some(ModeKind::Plan) + && let Some(effort) = self.config.plan_mode_reasoning_effort + { + mask.reasoning_effort = Some(Some(effort)); + } self.active_collaboration_mask = Some(mask); self.update_collaboration_mode_indicator(); self.refresh_model_display(); @@ -6817,8 +6958,13 @@ impl ChatWidget { pub(crate) fn submit_user_message_with_mode( &mut self, text: String, - collaboration_mode: CollaborationModeMask, + 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.agent_turn_running && self.active_collaboration_mask.as_ref() != Some(&collaboration_mode) { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap index 3eb172c101..2404dced5d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1548 expression: popup --- Select Reasoning Level for gpt-5.1-codex-max diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 8646033284..f04d02f658 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2177,6 +2177,243 @@ async fn submit_user_message_with_mode_sets_coding_collaboration_mode() { } } +#[tokio::test] +async fn reasoning_selection_in_plan_mode_opens_scope_prompt_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + let plan_mask = collaboration_modes::plan_mask(chat.models_manager.as_ref()) + .expect("expected plan collaboration mode"); + chat.set_collaboration_mask(plan_mask); + let _ = drain_insert_history(&mut rx); + set_chatgpt_auth(&mut chat); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); + + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + chat.open_reasoning_popup(preset); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let event = rx.try_recv().expect("expected AppEvent"); + assert_matches!( + event, + AppEvent::OpenPlanReasoningScopePrompt { + model, + effort: Some(_) + } if model == "gpt-5.1-codex-max" + ); +} + +#[tokio::test] +async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_scope_prompt_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + let plan_mask = collaboration_modes::plan_mask(chat.models_manager.as_ref()) + .expect("expected plan collaboration mode"); + chat.set_collaboration_mask(plan_mask); + let _ = drain_insert_history(&mut rx); + set_chatgpt_auth(&mut chat); + + let current_preset = get_available_model(&chat, "gpt-5.1-codex-max"); + chat.set_reasoning_effort(Some(current_preset.default_reasoning_effort)); + + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + chat.open_reasoning_popup(preset); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::UpdateModel(model) if model == "gpt-5.1-codex-max" + )), + "expected model update event; events: {events:?}" + ); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))), + "expected reasoning update event; events: {events:?}" + ); +} + +#[tokio::test] +async fn reasoning_selection_in_plan_mode_matching_plan_effort_but_different_global_opens_scope_prompt() + { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + let plan_mask = collaboration_modes::plan_mask(chat.models_manager.as_ref()) + .expect("expected plan collaboration mode"); + chat.set_collaboration_mask(plan_mask); + let _ = drain_insert_history(&mut rx); + set_chatgpt_auth(&mut chat); + + // Reproduce: Plan effective reasoning remains the preset (medium), but the + // global default differs (high). Pressing Enter on the current Plan choice + // should open the scope prompt rather than silently rewriting the global default. + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); + + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + chat.open_reasoning_popup(preset); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let event = rx.try_recv().expect("expected AppEvent"); + assert_matches!( + event, + AppEvent::OpenPlanReasoningScopePrompt { + model, + effort: Some(ReasoningEffortConfig::Medium) + } if model == "gpt-5.1-codex-max" + ); +} + +#[tokio::test] +async fn plan_mode_reasoning_override_is_marked_current_in_reasoning_popup() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + set_chatgpt_auth(&mut chat); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); + chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Low)); + + let plan_mask = collaboration_modes::plan_mask(chat.models_manager.as_ref()) + .expect("expected plan collaboration mode"); + chat.set_collaboration_mask(plan_mask); + + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + chat.open_reasoning_popup(preset); + + let popup = render_bottom_popup(&chat, 100); + assert!(popup.contains("Low (current)")); + assert!( + !popup.contains("High (current)"), + "expected Plan override to drive current reasoning label, got: {popup}" + ); +} + +#[tokio::test] +async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_prompt_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + let plan_mask = collaboration_modes::plan_mask(chat.models_manager.as_ref()) + .expect("expected plan collaboration mode"); + chat.set_collaboration_mask(plan_mask); + let _ = drain_insert_history(&mut rx); + set_chatgpt_auth(&mut chat); + + let preset = get_available_model(&chat, "gpt-5"); + chat.open_reasoning_popup(preset); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::UpdateModel(model) if model == "gpt-5" + )), + "expected model update event; events: {events:?}" + ); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))), + "expected reasoning update event; events: {events:?}" + ); +} + +#[tokio::test] +async fn plan_reasoning_scope_popup_all_modes_persists_global_and_plan_override() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + chat.open_plan_reasoning_scope_prompt( + "gpt-5.1-codex-max".to_string(), + Some(ReasoningEffortConfig::High), + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) + )), + "expected plan override to be updated; events: {events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::PersistPlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) + )), + "expected updated plan override to be persisted; events: {events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::PersistModelSelection { model, effort: Some(ReasoningEffortConfig::High) } + if model == "gpt-5.1-codex-max" + )), + "expected global model reasoning selection persistence; events: {events:?}" + ); +} + +#[tokio::test] +async fn plan_reasoning_scope_popup_mentions_selected_reasoning() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Low)); + chat.open_plan_reasoning_scope_prompt( + "gpt-5.1-codex-max".to_string(), + Some(ReasoningEffortConfig::Medium), + ); + + let popup = render_bottom_popup(&chat, 100); + assert!(popup.contains("Choose where to apply medium reasoning.")); + assert!(popup.contains("Always use medium reasoning in Plan mode.")); + assert!(popup.contains("Apply to Plan mode override")); + assert!(popup.contains("Apply to global default and Plan mode override")); + assert!(popup.contains("user-chosen Plan override (low)")); +} + +#[tokio::test] +async fn plan_reasoning_scope_popup_mentions_built_in_plan_default_when_no_override() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + chat.open_plan_reasoning_scope_prompt( + "gpt-5.1-codex-max".to_string(), + Some(ReasoningEffortConfig::Medium), + ); + + let popup = render_bottom_popup(&chat, 100); + assert!(popup.contains("built-in Plan default (medium)")); +} + +#[tokio::test] +async fn plan_reasoning_scope_popup_plan_only_does_not_update_all_modes_reasoning() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + chat.open_plan_reasoning_scope_prompt( + "gpt-5.1-codex-max".to_string(), + Some(ReasoningEffortConfig::High), + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) + )), + "expected plan-only reasoning update; events: {events:?}" + ); + assert!( + events + .iter() + .all(|event| !matches!(event, AppEvent::UpdateReasoningEffort(_))), + "did not expect all-modes reasoning update; events: {events:?}" + ); +} + #[tokio::test] async fn submit_user_message_with_mode_errors_when_mode_changes_during_running_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; @@ -3958,7 +4195,29 @@ async fn set_reasoning_effort_updates_active_collaboration_mask() { chat.set_reasoning_effort(None); - assert_eq!(chat.current_reasoning_effort(), None); + assert_eq!( + chat.current_reasoning_effort(), + Some(ReasoningEffortConfig::Medium) + ); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); +} + +#[tokio::test] +async fn set_reasoning_effort_does_not_override_active_plan_override() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High)); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + + chat.set_reasoning_effort(Some(ReasoningEffortConfig::Low)); + + assert_eq!( + chat.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); } diff --git a/docs/config.md b/docs/config.md index 9d53538cae..ed80e559c1 100644 --- a/docs/config.md +++ b/docs/config.md @@ -32,4 +32,13 @@ The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schem Codex stores "do not show again" flags for some UI prompts under the `[notice]` table. +## Plan mode defaults + +`plan_mode_reasoning_effort` lets you set a Plan-mode-specific default reasoning +effort override. When unset, Plan mode uses the built-in Plan preset default +(currently `medium`). When explicitly set (including `none`), it overrides the +Plan preset. The string value `none` means "no reasoning" (an explicit Plan +override), not "inherit the global default". There is currently no separate +config value for "follow the global default in Plan mode". + Ctrl+C/Ctrl+D quitting uses a ~1 second double-press hint (`ctrl + c again to quit`). From b237f7cbb124563faf6485b70db13886653869b3 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 20 Feb 2026 20:10:51 -0800 Subject: [PATCH 022/282] Add experimental realtime websocket backend prompt override (#12418) - add top-level `experimental_realtime_ws_backend_prompt` config key (experimental / do not use) and include it in config schema - apply the override only to `Op::RealtimeConversation` websocket `backend_prompt`, with config + realtime tests --- codex-rs/core/config.schema.json | 4 ++ codex-rs/core/src/config/mod.rs | 44 +++++++++++++++-- codex-rs/core/src/realtime_conversation.rs | 11 ++--- .../core/tests/suite/realtime_conversation.rs | 47 ++++++++++++++++++- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index cda5b8eaba..23963196a1 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1544,6 +1544,10 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, + "experimental_realtime_ws_backend_prompt": { + "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport backend prompt (the `Op::RealtimeConversation` `/ws` session.create backend_prompt) without changing normal prompts.", + "type": "string" + }, "experimental_realtime_ws_base_url": { "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport base URL (the `Op::RealtimeConversation` `/ws` connection) without changing normal provider HTTP requests.", "type": "string" diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 168633194e..edaabed1fa 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -405,7 +405,10 @@ pub struct Config { /// websocket transport base URL (the `Op::RealtimeConversation` `/ws` /// connection) without changing normal provider HTTP requests. pub experimental_realtime_ws_base_url: Option, - + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport backend prompt (the `Op::RealtimeConversation` + /// `/ws` session.create backend_prompt) without changing normal prompts. + pub experimental_realtime_ws_backend_prompt: Option, /// When set, restricts ChatGPT login to a specific workspace identifier. pub forced_chatgpt_workspace_id: Option, @@ -1136,7 +1139,10 @@ pub struct ConfigToml { /// websocket transport base URL (the `Op::RealtimeConversation` `/ws` /// connection) without changing normal provider HTTP requests. pub experimental_realtime_ws_base_url: Option, - + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport backend prompt (the `Op::RealtimeConversation` + /// `/ws` session.create backend_prompt) without changing normal prompts. + pub experimental_realtime_ws_backend_prompt: Option, pub projects: Option>, /// Controls the web search tool mode: disabled, cached, or live. @@ -2065,6 +2071,7 @@ impl Config { .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), experimental_realtime_ws_base_url: cfg.experimental_realtime_ws_base_url, + experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt, forced_chatgpt_workspace_id, forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, @@ -4607,6 +4614,7 @@ model_verbosity = "high" personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_realtime_ws_base_url: None, + experimental_realtime_ws_backend_prompt: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -4728,6 +4736,7 @@ model_verbosity = "high" personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_realtime_ws_base_url: None, + experimental_realtime_ws_backend_prompt: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -4847,6 +4856,7 @@ model_verbosity = "high" personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_realtime_ws_base_url: None, + experimental_realtime_ws_backend_prompt: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -4952,6 +4962,7 @@ model_verbosity = "high" personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_realtime_ws_base_url: None, + experimental_realtime_ws_backend_prompt: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -5738,7 +5749,6 @@ trust_level = "untrusted" ); Ok(()) } - #[test] fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( @@ -5766,6 +5776,34 @@ experimental_realtime_ws_base_url = "http://127.0.0.1:8011" ); Ok(()) } + + #[test] + fn experimental_realtime_ws_backend_prompt_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_realtime_ws_backend_prompt = "prompt from config" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_realtime_ws_backend_prompt.as_deref(), + Some("prompt from config") + ); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.experimental_realtime_ws_backend_prompt.as_deref(), + Some("prompt from config") + ); + Ok(()) + } } #[cfg(test)] diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index b41b4bd0d4..b67874593f 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -174,18 +174,17 @@ pub(crate) async fn handle_start( if let Some(realtime_ws_base_url) = &config.experimental_realtime_ws_base_url { api_provider.base_url = realtime_ws_base_url.clone(); } + let prompt = config + .experimental_realtime_ws_backend_prompt + .clone() + .unwrap_or(params.prompt); let requested_session_id = params .session_id .or_else(|| Some(sess.conversation_id.to_string())); let events_rx = match sess .conversation - .start( - api_provider, - None, - params.prompt, - requested_session_id.clone(), - ) + .start(api_provider, None, prompt, requested_session_id.clone()) .await { Ok(events_rx) => events_rx, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index ddc63385de..3d94ccf533 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -358,7 +358,6 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { server.shutdown().await; Ok(()) } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Result<()> { skip_if_no_network!(Ok(())); @@ -413,3 +412,49 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul realtime_server.shutdown().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![ + vec![], + vec![vec![json!({ + "type": "session.created", + "session": { "id": "sess_override" } + })]], + ]) + .await; + + let mut builder = test_codex().with_config(|config| { + config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); + }); + let test = builder.build_with_websocket_server(&server).await?; + assert!(server.wait_for_handshakes(1, Duration::from_secs(2)).await); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "prompt from op".to_string(), + session_id: None, + })) + .await?; + + let session_created = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) => Some(session_id.clone()), + _ => None, + }) + .await; + assert_eq!(session_created, "sess_override"); + + let connections = server.connections(); + assert_eq!(connections.len(), 2); + assert_eq!( + connections[1][0].body_json()["session"]["backend_prompt"].as_str(), + Some("prompt from config") + ); + + server.shutdown().await; + Ok(()) +} From a73efab8dd6ca8371dc637ae67d4de991d69fb2e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 20 Feb 2026 20:36:04 -0800 Subject: [PATCH 023/282] fix: address flakiness in thread_resume_rejoins_running_thread_even_with_override_mismatch (#12381) ## Why `thread/resume` responses for already-running threads can be reported as `Idle` even while a turn is still in progress. This is caused by a timing window where the runtime watch state has not yet observed the running-thread transition, so API clients can receive stale status information at resume time. Possibly related: https://github.com/openai/codex/pull/11786 ## What - Add a shared status normalization helper, `resolve_thread_status`, in `codex-rs/app-server/src/thread_status.rs` that resolves `Idle`/`NotLoaded` to `Active { active_flags: [] }` when an in-progress turn is known. - Reuse this helper across thread response paths in `codex-rs/app-server/src/codex_message_processor.rs` (including `thread/start`, `thread/unarchive`, `thread/read`, `thread/resume`, `thread/fork`, and review/thread-started notification responses). - In `handle_pending_thread_resume_request`, use both the in-memory `active_turn_snapshot` and the resumed rollout turns to decide whether a turn is in progress before resolving thread status for the response. - Extend `thread_status` tests to validate the new status-resolution behavior directly. ## Verification - `cargo test -p codex-app-server suite::v2::thread_resume::thread_resume_rejoins_running_thread_even_with_override_mismatch` --- .../app-server/src/codex_message_processor.rs | 81 ++++++++++++------- codex-rs/app-server/src/thread_status.rs | 47 +++++++++++ 2 files changed, 101 insertions(+), 27 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 72dc7eaea2..17a67fa4dc 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -11,6 +11,7 @@ use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; use crate::thread_status::ThreadWatchManager; +use crate::thread_status::resolve_thread_status; use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; @@ -2053,10 +2054,12 @@ impl CodexMessageProcessor { .upsert_thread(thread.clone()) .await; - thread.status = self - .thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + false, + ); let response = ThreadStartResponse { thread: thread.clone(), @@ -2372,10 +2375,12 @@ impl CodexMessageProcessor { match result { Ok(mut thread) => { - thread.status = self - .thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + false, + ); self.attach_thread_name(thread_id, &mut thread).await; let thread_id = thread.id.clone(); let response = ThreadUnarchiveResponse { thread }; @@ -2769,10 +2774,12 @@ impl CodexMessageProcessor { } } - thread.status = self - .thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + false, + ); let response = ThreadReadResponse { thread }; self.outgoing.send_response(request_id, response).await; } @@ -2949,10 +2956,12 @@ impl CodexMessageProcessor { .upsert_thread(thread.clone()) .await; - thread.status = self - .thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + false, + ); let response = ThreadResumeResponse { thread, @@ -3475,10 +3484,12 @@ impl CodexMessageProcessor { .upsert_thread(thread.clone()) .await; - thread.status = self - .thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + false, + ); let response = ThreadForkResponse { thread: thread.clone(), @@ -5616,10 +5627,12 @@ impl CodexMessageProcessor { self.thread_watch_manager .upsert_thread(thread.clone()) .await; - thread.status = self - .thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + false, + ); let notif = ThreadStartedNotification { thread }; self.outgoing .send_server_notification(ServerNotification::ThreadStarted(notif)) @@ -6283,6 +6296,9 @@ async fn handle_pending_thread_resume_request( let state = thread_state.lock().await; state.active_turn_snapshot() }; + let mut has_in_progress_turn = active_turn + .as_ref() + .is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress)); let request_id = pending.request_id; let connection_id = request_id.connection_id; @@ -6309,9 +6325,20 @@ async fn handle_pending_thread_resume_request( return; } }; - thread.status = thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; + + has_in_progress_turn = has_in_progress_turn + || thread + .turns + .iter() + .any(|turn| matches!(turn.status, TurnStatus::InProgress)); + + let status = resolve_thread_status( + thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + has_in_progress_turn, + ); + thread.status = status; match find_thread_name_by_id(codex_home, &conversation_id).await { Ok(thread_name) => thread.name = thread_name, diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 48e3d9ab08..5693631111 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -239,6 +239,22 @@ impl ThreadWatchManager { } } +pub(crate) fn resolve_thread_status( + status: ThreadStatus, + has_in_progress_turn: bool, +) -> ThreadStatus { + // Running-turn events can arrive before the watch runtime state is observed by + // the listener loop. In that window we prefer to reflect a real active turn as + // `Active` instead of `Idle`/`NotLoaded`. + if has_in_progress_turn && matches!(status, ThreadStatus::Idle | ThreadStatus::NotLoaded) { + return ThreadStatus::Active { + active_flags: Vec::new(), + }; + } + + status +} + #[derive(Default)] struct ThreadWatchState { runtime_by_thread_id: HashMap, @@ -459,6 +475,37 @@ mod tests { ); } + #[test] + fn resolves_in_progress_turn_to_active_status() { + let status = resolve_thread_status(ThreadStatus::Idle, true); + assert_eq!( + status, + ThreadStatus::Active { + active_flags: Vec::new(), + } + ); + + let status = resolve_thread_status(ThreadStatus::NotLoaded, true); + assert_eq!( + status, + ThreadStatus::Active { + active_flags: Vec::new(), + } + ); + } + + #[test] + fn keeps_status_when_no_in_progress_turn() { + assert_eq!( + resolve_thread_status(ThreadStatus::Idle, false), + ThreadStatus::Idle + ); + assert_eq!( + resolve_thread_status(ThreadStatus::SystemError, false), + ThreadStatus::SystemError + ); + } + #[tokio::test] async fn system_error_sets_idle_flag_until_next_turn() { let manager = ThreadWatchManager::new(); From 48af93399e1e5105fe7a770e61e572b070dc3cf9 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 20 Feb 2026 20:43:36 -0800 Subject: [PATCH 024/282] feat: use OAI Responses API MessagePhase type directly in App Server v2 (#12422) https://github.com/openai/codex/pull/10455 introduced the `phase` field, and then https://github.com/openai/codex/pull/12072 introduced a `MessagePhase` type in `v2.rs` that paralleled the `MessagePhase` type in `codex-rs/protocol/src/models.rs`. The app server protocol prefers `camelCase` while the Responses API uses `snake_case`, so this meant we had two versions of `MessagePhase` with different serialization rules. When the app server protocol refers to types from the Responses API, we use the wire format of the the Responses API even though it is inconsistent with the app server API. This PR deletes `MessagePhase` from `v2.rs` and consolidates on the Responses API version to eliminate confusion. --- .../schema/json/ServerNotification.json | 13 +++------- .../json/v2/ItemCompletedNotification.json | 22 ++++++++++++---- .../json/v2/ItemStartedNotification.json | 22 ++++++++++++---- .../schema/json/v2/ReviewStartResponse.json | 22 ++++++++++++---- .../schema/json/v2/ThreadForkResponse.json | 22 ++++++++++++---- .../schema/json/v2/ThreadListResponse.json | 22 ++++++++++++---- .../schema/json/v2/ThreadReadResponse.json | 22 ++++++++++++---- .../schema/json/v2/ThreadResumeResponse.json | 22 ++++++++++++---- .../json/v2/ThreadRollbackResponse.json | 22 ++++++++++++---- .../schema/json/v2/ThreadStartResponse.json | 22 ++++++++++++---- .../json/v2/ThreadStartedNotification.json | 22 ++++++++++++---- .../json/v2/ThreadUnarchiveResponse.json | 22 ++++++++++++---- .../json/v2/TurnCompletedNotification.json | 22 ++++++++++++---- .../schema/json/v2/TurnStartResponse.json | 22 ++++++++++++---- .../json/v2/TurnStartedNotification.json | 22 ++++++++++++---- .../schema/typescript/v2/MessagePhase.ts | 5 ---- .../schema/typescript/v2/ThreadItem.ts | 2 +- .../schema/typescript/v2/index.ts | 1 - .../src/protocol/thread_history.rs | 14 +++++------ .../app-server-protocol/src/protocol/v2.rs | 25 +++---------------- 20 files changed, 251 insertions(+), 117 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/MessagePhase.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 51c445bf14..bcdbdb33ed 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1618,7 +1618,7 @@ "phase": { "anyOf": [ { - "$ref": "#/definitions/MessagePhase2" + "$ref": "#/definitions/MessagePhase" }, { "type": "null" @@ -4660,13 +4660,6 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" - }, - "MessagePhase2": { "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", "oneOf": [ { @@ -5732,7 +5725,7 @@ "phase": { "anyOf": [ { - "$ref": "#/definitions/MessagePhase2" + "$ref": "#/definitions/MessagePhase" }, { "type": "null" @@ -7923,7 +7916,7 @@ "phase": { "anyOf": [ { - "$ref": "#/definitions/MessagePhase2" + "$ref": "#/definitions/MessagePhase" }, { "type": "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 1a0ef630a1..98d8a631e9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -237,11 +237,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "PatchApplyStatus": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 50e7e1e664..ee10e470e0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -237,11 +237,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "PatchApplyStatus": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index e71ebbdc7f..760477095f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -351,11 +351,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "PatchApplyStatus": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 55480c5bc7..71f9da2f3b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -420,11 +420,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "NetworkAccess": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 9e4e4d63b4..5907d13cea 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -374,11 +374,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "PatchApplyStatus": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index acb80dd56f..9944457c3e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -374,11 +374,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "PatchApplyStatus": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 884930d4d6..7778773a16 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -420,11 +420,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "NetworkAccess": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 555083baf2..c4bcdf7092 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -374,11 +374,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "PatchApplyStatus": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 2acd4da8fb..6b3d2ebc58 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -420,11 +420,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "NetworkAccess": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index a39ec5151c..2fbd9538f3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -374,11 +374,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "PatchApplyStatus": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index e18582f069..df3e7beee5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -374,11 +374,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "PatchApplyStatus": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index e20e71be29..2c8eec7f90 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -351,11 +351,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "PatchApplyStatus": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 4fb088a13e..5585cd455a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -351,11 +351,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "PatchApplyStatus": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 4e7f82f4e5..4462c314d2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -351,11 +351,23 @@ "type": "string" }, "MessagePhase": { - "enum": [ - "commentary", - "finalAnswer" - ], - "type": "string" + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] }, "PatchApplyStatus": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MessagePhase.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MessagePhase.ts deleted file mode 100644 index 80d7236e74..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/MessagePhase.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 MessagePhase = "commentary" | "finalAnswer"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index cbdad0bf2b..dee39cd5db 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -1,6 +1,7 @@ // 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 { MessagePhase } from "../MessagePhase"; import type { JsonValue } from "../serde_json/JsonValue"; import type { CollabAgentState } from "./CollabAgentState"; import type { CollabAgentTool } from "./CollabAgentTool"; @@ -11,7 +12,6 @@ import type { FileUpdateChange } from "./FileUpdateChange"; import type { McpToolCallError } from "./McpToolCallError"; import type { McpToolCallResult } from "./McpToolCallResult"; import type { McpToolCallStatus } from "./McpToolCallStatus"; -import type { MessagePhase } from "./MessagePhase"; import type { PatchApplyStatus } from "./PatchApplyStatus"; import type { UserInput } from "./UserInput"; import type { WebSearchAction } from "./WebSearchAction"; 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 c2f8494785..ce2289c382 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -95,7 +95,6 @@ export type { McpToolCallProgressNotification } from "./McpToolCallProgressNotif export type { McpToolCallResult } from "./McpToolCallResult"; export type { McpToolCallStatus } from "./McpToolCallStatus"; export type { MergeStrategy } from "./MergeStrategy"; -export type { MessagePhase } from "./MessagePhase"; export type { Model } from "./Model"; export type { ModelListParams } from "./ModelListParams"; export type { ModelListResponse } from "./ModelListResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index fce6f4f263..c6d0cc00fe 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -16,7 +16,7 @@ use crate::protocol::v2::TurnError; use crate::protocol::v2::TurnStatus; use crate::protocol::v2::UserInput; use crate::protocol::v2::WebSearchAction; -use codex_protocol::models::MessagePhase as CoreMessagePhase; +use codex_protocol::models::MessagePhase; use codex_protocol::protocol::AgentReasoningEvent; use codex_protocol::protocol::AgentReasoningRawContentEvent; use codex_protocol::protocol::AgentStatus; @@ -190,17 +190,15 @@ impl ThreadHistoryBuilder { self.current_turn = Some(turn); } - fn handle_agent_message(&mut self, text: String, phase: Option) { + fn handle_agent_message(&mut self, text: String, phase: Option) { if text.is_empty() { return; } let id = self.next_item_id(); - self.ensure_turn().items.push(ThreadItem::AgentMessage { - id, - text, - phase: phase.map(Into::into), - }); + self.ensure_turn() + .items + .push(ThreadItem::AgentMessage { id, text, phase }); } fn handle_agent_reasoning(&mut self, payload: &AgentReasoningEvent) { @@ -1196,7 +1194,7 @@ mod tests { ThreadItem::AgentMessage { id: "item-1".into(), text: "Final reply".into(), - phase: Some(crate::protocol::v2::MessagePhase::FinalAnswer), + phase: Some(MessagePhase::FinalAnswer), } ); } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 4b3ffaf02f..43038d351c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -20,7 +20,7 @@ use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::mcp::Resource as McpResource; use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; use codex_protocol::mcp::Tool as McpTool; -use codex_protocol::models::MessagePhase as CoreMessagePhase; +use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ReasoningEffort; @@ -2666,24 +2666,6 @@ impl From for UserInput { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum MessagePhase { - Commentary, - FinalAnswer, -} - -impl From for MessagePhase { - fn from(value: CoreMessagePhase) -> Self { - match value { - CoreMessagePhase::Commentary => Self::Commentary, - CoreMessagePhase::FinalAnswer => Self::FinalAnswer, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -2873,7 +2855,7 @@ impl From for ThreadItem { ThreadItem::AgentMessage { id: agent.id, text, - phase: agent.phase.map(Into::into), + phase: agent.phase, } } CoreTurnItem::Plan(plan) => ThreadItem::Plan { @@ -3692,7 +3674,6 @@ mod tests { use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::items::WebSearchItem; - use codex_protocol::models::MessagePhase as CoreMessagePhase; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess; @@ -3902,7 +3883,7 @@ mod tests { content: vec![AgentMessageContent::Text { text: "final".to_string(), }], - phase: Some(CoreMessagePhase::FinalAnswer), + phase: Some(MessagePhase::FinalAnswer), }); assert_eq!( From dca9c40dd5f9b4ad8cca7b67c01ea055feecc2bf Mon Sep 17 00:00:00 2001 From: Yaroslav Volovich Date: Sat, 21 Feb 2026 05:15:21 +0000 Subject: [PATCH 025/282] test(app-server): wait for turn/completed in turn_start tests (#12376) ## Summary - switch a few app-server `turn_start` tests from `codex/event/task_complete` waits to `turn/completed` waits - avoid matching unrelated/background `task_complete` events - keep this flaky test fix separate from the /title feature PR ## Why On Windows ARM CI, these tests can return early after observing a generic `codex/event/task_complete` notification from another task. That can leave the mock Responses server with fewer calls than expected and fail the test with a wiremock verification mismatch. Using `turn/completed` matches the app-server turn lifecycle notification the tests actually care about. ## Validation - `cargo test -p codex-app-server turn_start_updates_sandbox_and_cwd_between_turns_v2 -- --nocapture` - `cargo test -p codex-app-server turn_start_exec_approval_ -- --nocapture` - `just fmt` --- codex-rs/app-server/tests/suite/v2/turn_start.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index d2d850176d..8d6db34731 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -847,7 +847,9 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { // Approve and wait for task completion mcp.send_response( request_id, - serde_json::json!({ "decision": codex_core::protocol::ReviewDecision::Approved }), + serde_json::to_value(CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::Accept, + })?, ) .await?; timeout( @@ -1035,7 +1037,7 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), + mcp.read_stream_until_notification_message("turn/completed"), ) .await??; @@ -1129,7 +1131,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { .await??; timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), + mcp.read_stream_until_notification_message("turn/completed"), ) .await??; mcp.clear_message_buffer(); From 1779feb6a7b5937a81e5134379e3a729416948ff Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 20 Feb 2026 21:36:12 -0800 Subject: [PATCH 026/282] ignore v1 in JSON schema codegen (#12408) ## Why The generated unnamespaced JSON envelope schemas (`ClientRequest` and `ServerNotification`) still contained both v1 and v2 variants, which pulled legacy v1/core types and v2 types into the same `definitions` graph. That caused `schemars` to produce numeric suffix names (for example `AskForApproval2`, `ByteRange2`, `MessagePhase2`). This PR moves JSON codegen toward v2-only output while preserving the unnamespaced envelope artifacts, and avoids reintroducing numeric-suffix tolerance by removing the v1/internal-only variants that caused the collisions in those envelope schemas. ## What Changed - In `codex-rs/app-server-protocol/src/export.rs`, JSON generation now excludes v1 schema artifacts (`v1/*`) while continuing to emit unnamespaced/root JSON schemas and the JSON bundle. - Added a narrow JSON v1 allowlist (`JSON_V1_ALLOWLIST`) so `InitializeParams` and `InitializeResponse` are still emitted. - Added JSON-only post-processing for the mixed envelope schemas before collision checks run: - `ClientRequest`: strips v1 request variants from the generated `oneOf` using the temporary `V1_CLIENT_REQUEST_METHODS` list - `ServerNotification`: strips v1 notifications plus the internal-only `rawResponseItem/completed` notification using the temporary `EXCLUDED_SERVER_NOTIFICATION_METHODS_FOR_JSON` list - Added a temporary local-definition pruning pass for those envelope schemas so now-unreferenced v1/core definitions are removed from `definitions` after method filtering. - Updated the variant-title naming heuristic for single-property literal object variants to use the literal value (when available), avoiding collisions like multiple `state`-only variants all deriving the same title. - Collision handling remains fail-fast (no numeric suffix fallback map in this PR path). ## Verification - `just write-app-server-schema` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/12408). * __->__ #12408 * #12406 --- .../schema/json/ClientRequest.json | 1365 ---- .../schema/json/EventMsg.json | 6 +- .../schema/json/ServerNotification.json | 5898 ----------------- .../codex_app_server_protocol.schemas.json | 3996 +---------- .../v1/AddConversationListenerParams.json | 22 - .../AddConversationSubscriptionResponse.json | 13 - .../json/v1/ArchiveConversationParams.json | 22 - .../json/v1/ArchiveConversationResponse.json | 5 - .../json/v1/AuthStatusChangeNotification.json | 46 - .../json/v1/CancelLoginChatGptParams.json | 13 - .../json/v1/CancelLoginChatGptResponse.json | 5 - .../json/v1/ExecOneOffCommandParams.json | 225 - .../json/v1/ExecOneOffCommandResponse.json | 22 - .../json/v1/ForkConversationParams.json | 195 - .../json/v1/ForkConversationResponse.json | 5776 ---------------- .../schema/json/v1/GetAuthStatusParams.json | 19 - .../schema/json/v1/GetAuthStatusResponse.json | 57 - .../json/v1/GetConversationSummaryParams.json | 35 - .../v1/GetConversationSummaryResponse.json | 190 - .../schema/json/v1/GetUserAgentResponse.json | 13 - .../json/v1/GetUserSavedConfigResponse.json | 366 - .../schema/json/v1/GitDiffToRemoteParams.json | 13 - .../json/v1/GitDiffToRemoteResponse.json | 22 - .../json/v1/InterruptConversationParams.json | 18 - .../v1/InterruptConversationResponse.json | 23 - .../json/v1/ListConversationsParams.json | 30 - .../json/v1/ListConversationsResponse.json | 199 - .../schema/json/v1/LoginApiKeyParams.json | 13 - .../schema/json/v1/LoginApiKeyResponse.json | 5 - .../v1/LoginChatGptCompleteNotification.json | 24 - .../schema/json/v1/LoginChatGptResponse.json | 17 - .../schema/json/v1/LogoutChatGptResponse.json | 5 - .../schema/json/v1/NewConversationParams.json | 161 - .../json/v1/NewConversationResponse.json | 48 - .../v1/RemoveConversationListenerParams.json | 13 - ...emoveConversationSubscriptionResponse.json | 5 - .../json/v1/ResumeConversationParams.json | 984 --- .../json/v1/ResumeConversationResponse.json | 5776 ---------------- .../schema/json/v1/SendUserMessageParams.json | 165 - .../json/v1/SendUserMessageResponse.json | 5 - .../schema/json/v1/SendUserTurnParams.json | 482 -- .../schema/json/v1/SendUserTurnResponse.json | 5 - .../v1/SessionConfiguredNotification.json | 5798 ---------------- .../schema/json/v1/SetDefaultModelParams.json | 37 - .../json/v1/SetDefaultModelResponse.json | 5 - .../schema/json/v1/UserInfoResponse.json | 13 - codex-rs/app-server-protocol/src/export.rs | 372 +- 47 files changed, 204 insertions(+), 32323 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/AddConversationListenerParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/AddConversationSubscriptionResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/ArchiveConversationParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/ArchiveConversationResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/AuthStatusChangeNotification.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/ForkConversationParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/GetAuthStatusParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/GetAuthStatusResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/GetConversationSummaryParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/GetConversationSummaryResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/GetUserAgentResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/GetUserSavedConfigResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/InterruptConversationParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/InterruptConversationResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/ListConversationsParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/ListConversationsResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/LoginApiKeyParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/LoginApiKeyResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/LoginChatGptCompleteNotification.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/LoginChatGptResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/LogoutChatGptResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/NewConversationParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/NewConversationResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/RemoveConversationListenerParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/RemoveConversationSubscriptionResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/SendUserMessageParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/SendUserMessageResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/SendUserTurnResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/SetDefaultModelParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/SetDefaultModelResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v1/UserInfoResponse.json diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 76ba36735c..cee6e6d04b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5,21 +5,6 @@ "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" }, - "AddConversationListenerParams": { - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "experimentalRawEvents": { - "default": false, - "type": "boolean" - } - }, - "required": [ - "conversationId" - ], - "type": "object" - }, "AppsListParams": { "description": "EXPERIMENTAL - list available apps/connectors.", "properties": { @@ -53,21 +38,6 @@ }, "type": "object" }, - "ArchiveConversationParams": { - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "rolloutPath" - ], - "type": "object" - }, "AskForApproval": { "oneOf": [ { @@ -110,53 +80,6 @@ } ] }, - "AskForApproval2": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval2", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, "ByteRange": { "properties": { "end": { @@ -187,17 +110,6 @@ ], "type": "object" }, - "CancelLoginChatGptParams": { - "properties": { - "loginId": { - "type": "string" - } - }, - "required": [ - "loginId" - ], - "type": "object" - }, "ClientInfo": { "properties": { "name": { @@ -443,44 +355,6 @@ ], "type": "object" }, - "ExecOneOffCommandParams": { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "sandboxPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxPolicy2" - }, - { - "type": "null" - } - ] - }, - "timeoutMs": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "command" - ], - "type": "object" - }, "ExperimentalFeatureListParams": { "properties": { "cursor": { @@ -538,37 +412,6 @@ ], "type": "object" }, - "ForkConversationParams": { - "properties": { - "conversationId": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "overrides": { - "anyOf": [ - { - "$ref": "#/definitions/NewConversationParams" - }, - { - "type": "null" - } - ] - }, - "path": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "FunctionCallOutputBody": { "anyOf": [ { @@ -679,51 +522,6 @@ }, "type": "object" }, - "GetAuthStatusParams": { - "properties": { - "includeToken": { - "type": [ - "boolean", - "null" - ] - }, - "refreshToken": { - "type": [ - "boolean", - "null" - ] - } - }, - "type": "object" - }, - "GetConversationSummaryParams": { - "anyOf": [ - { - "properties": { - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "rolloutPath" - ], - "title": "RolloutPathGetConversationSummaryParams", - "type": "object" - }, - { - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "conversationId" - ], - "title": "ConversationIdGetConversationSummaryParams", - "type": "object" - } - ] - }, "GhostCommit": { "description": "Details of a ghost commit created from a repository state.", "properties": { @@ -756,17 +554,6 @@ ], "type": "object" }, - "GitDiffToRemoteParams": { - "properties": { - "cwd": { - "type": "string" - } - }, - "required": [ - "cwd" - ], - "type": "object" - }, "HazelnutScope": { "enum": [ "example", @@ -818,141 +605,6 @@ ], "type": "object" }, - "InputItem": { - "oneOf": [ - { - "properties": { - "data": { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/V1TextElement" - }, - "type": "array" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "TextInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "image_url": { - "type": "string" - } - }, - "required": [ - "image_url" - ], - "type": "object" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "ImageInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "path": { - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "type": { - "enum": [ - "localImage" - ], - "title": "LocalImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "LocalImageInputItem", - "type": "object" - } - ] - }, - "InterruptConversationParams": { - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "conversationId" - ], - "type": "object" - }, - "ListConversationsParams": { - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "modelProviders": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "pageSize": { - "format": "uint", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - }, "ListMcpServerStatusParams": { "properties": { "cursor": { @@ -1112,17 +764,6 @@ } ] }, - "LoginApiKeyParams": { - "properties": { - "apiKey": { - "type": "string" - } - }, - "required": [ - "apiKey" - ], - "type": "object" - }, "McpServerOauthLoginParams": { "properties": { "name": { @@ -1219,94 +860,6 @@ ], "type": "string" }, - "NetworkAccess2": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "NewConversationParams": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval2" - }, - { - "type": "null" - } - ] - }, - "baseInstructions": { - "type": [ - "string", - "null" - ] - }, - "compactPrompt": { - "type": [ - "string", - "null" - ] - }, - "config": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "developerInstructions": { - "type": [ - "string", - "null" - ] - }, - "includeApplyPatchTool": { - "type": [ - "boolean", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelProvider": { - "type": [ - "string", - "null" - ] - }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "sandbox": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode2" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "Personality": { "enum": [ "none", @@ -1371,57 +924,6 @@ } ] }, - "ReadOnlyAccess2": { - "description": "Determines how read-only file access is granted inside a restricted sandbox.", - "oneOf": [ - { - "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", - "properties": { - "include_platform_defaults": { - "default": true, - "description": "Include built-in platform read roots required for basic process execution.", - "type": "boolean" - }, - "readable_roots": { - "description": "Additional absolute roots that should be readable.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccess2Type", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess2", - "type": "object" - }, - { - "description": "Allow unrestricted file reads.", - "properties": { - "type": { - "enum": [ - "full-access" - ], - "title": "FullAccessReadOnlyAccess2Type", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess2", - "type": "object" - } - ] - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1522,39 +1024,6 @@ } ] }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "RemoveConversationListenerParams": { - "properties": { - "subscriptionId": { - "type": "string" - } - }, - "required": [ - "subscriptionId" - ], - "type": "object" - }, "RequestId": { "anyOf": [ { @@ -1922,46 +1391,6 @@ } ] }, - "ResumeConversationParams": { - "properties": { - "conversationId": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history": { - "items": { - "$ref": "#/definitions/ResponseItem" - }, - "type": [ - "array", - "null" - ] - }, - "overrides": { - "anyOf": [ - { - "$ref": "#/definitions/NewConversationParams" - }, - { - "type": "null" - } - ] - }, - "path": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ReviewDelivery": { "enum": [ "inline", @@ -2095,14 +1524,6 @@ ], "type": "string" }, - "SandboxMode2": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - }, "SandboxPolicy": { "oneOf": [ { @@ -2218,216 +1639,6 @@ } ] }, - "SandboxPolicy2": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicy2Type", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy2", - "type": "object" - }, - { - "description": "Read-only access configuration.", - "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess2" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicy2Type", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy2", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess2" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicy2Type", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy2", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "read_only_access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess2" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicy2Type", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy2", - "type": "object" - } - ] - }, - "SendUserMessageParams": { - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "items": { - "items": { - "$ref": "#/definitions/InputItem" - }, - "type": "array" - } - }, - "required": [ - "conversationId", - "items" - ], - "type": "object" - }, - "SendUserTurnParams": { - "properties": { - "approvalPolicy": { - "$ref": "#/definitions/AskForApproval2" - }, - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "cwd": { - "type": "string" - }, - "effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "items": { - "items": { - "$ref": "#/definitions/InputItem" - }, - "type": "array" - }, - "model": { - "type": "string" - }, - "outputSchema": { - "description": "Optional JSON Schema used to constrain the final assistant message for this turn." - }, - "sandboxPolicy": { - "$ref": "#/definitions/SandboxPolicy2" - }, - "summary": { - "$ref": "#/definitions/ReasoningSummary" - } - }, - "required": [ - "approvalPolicy", - "conversationId", - "cwd", - "items", - "model", - "sandboxPolicy", - "summary" - ], - "type": "object" - }, - "SetDefaultModelParams": { - "properties": { - "model": { - "type": [ - "string", - "null" - ] - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "Settings": { "description": "Settings for a collaboration mode.", "properties": { @@ -2667,9 +1878,6 @@ ], "type": "object" }, - "ThreadId": { - "type": "string" - }, "ThreadListParams": { "properties": { "archived": { @@ -3245,50 +2453,6 @@ } ] }, - "V1ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "V1TextElement": { - "properties": { - "byteRange": { - "allOf": [ - { - "$ref": "#/definitions/V1ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byteRange" - ], - "type": "object" - }, "WebSearchAction": { "oneOf": [ { @@ -4321,510 +3485,6 @@ "title": "Account/readRequest", "type": "object" }, - { - "description": "DEPRECATED APIs below", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "newConversation" - ], - "title": "NewConversationRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/NewConversationParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "NewConversationRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "getConversationSummary" - ], - "title": "GetConversationSummaryRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/GetConversationSummaryParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "GetConversationSummaryRequest", - "type": "object" - }, - { - "description": "List recorded Codex conversations (rollouts) with optional pagination and search.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "listConversations" - ], - "title": "ListConversationsRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ListConversationsParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "ListConversationsRequest", - "type": "object" - }, - { - "description": "Resume a recorded Codex conversation from a rollout file.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "resumeConversation" - ], - "title": "ResumeConversationRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ResumeConversationParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "ResumeConversationRequest", - "type": "object" - }, - { - "description": "Fork a recorded Codex conversation into a new session.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "forkConversation" - ], - "title": "ForkConversationRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ForkConversationParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "ForkConversationRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "archiveConversation" - ], - "title": "ArchiveConversationRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ArchiveConversationParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "ArchiveConversationRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "sendUserMessage" - ], - "title": "SendUserMessageRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SendUserMessageParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "SendUserMessageRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "sendUserTurn" - ], - "title": "SendUserTurnRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SendUserTurnParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "SendUserTurnRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "interruptConversation" - ], - "title": "InterruptConversationRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/InterruptConversationParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "InterruptConversationRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "addConversationListener" - ], - "title": "AddConversationListenerRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/AddConversationListenerParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "AddConversationListenerRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "removeConversationListener" - ], - "title": "RemoveConversationListenerRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/RemoveConversationListenerParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "RemoveConversationListenerRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "gitDiffToRemote" - ], - "title": "GitDiffToRemoteRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/GitDiffToRemoteParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "GitDiffToRemoteRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "loginApiKey" - ], - "title": "LoginApiKeyRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/LoginApiKeyParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "LoginApiKeyRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "loginChatGpt" - ], - "title": "LoginChatGptRequestMethod", - "type": "string" - }, - "params": { - "type": "null" - } - }, - "required": [ - "id", - "method" - ], - "title": "LoginChatGptRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "cancelLoginChatGpt" - ], - "title": "CancelLoginChatGptRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/CancelLoginChatGptParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "CancelLoginChatGptRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "logoutChatGpt" - ], - "title": "LogoutChatGptRequestMethod", - "type": "string" - }, - "params": { - "type": "null" - } - }, - "required": [ - "id", - "method" - ], - "title": "LogoutChatGptRequest", - "type": "object" - }, - { - "description": "DEPRECATED in favor of GetAccount", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "getAuthStatus" - ], - "title": "GetAuthStatusRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/GetAuthStatusParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "GetAuthStatusRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "getUserSavedConfig" - ], - "title": "GetUserSavedConfigRequestMethod", - "type": "string" - }, - "params": { - "type": "null" - } - }, - "required": [ - "id", - "method" - ], - "title": "GetUserSavedConfigRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "setDefaultModel" - ], - "title": "SetDefaultModelRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SetDefaultModelParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "SetDefaultModelRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "getUserAgent" - ], - "title": "GetUserAgentRequestMethod", - "type": "string" - }, - "params": { - "type": "null" - } - }, - "required": [ - "id", - "method" - ], - "title": "GetUserAgentRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "userInfo" - ], - "title": "UserInfoRequestMethod", - "type": "string" - }, - "params": { - "type": "null" - } - }, - "required": [ - "id", - "method" - ], - "title": "UserInfoRequest", - "type": "object" - }, { "properties": { "id": { @@ -4848,31 +3508,6 @@ ], "title": "FuzzyFileSearchRequest", "type": "object" - }, - { - "description": "Execute a command (argv vector) under the server's sandbox.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "execOneOffCommand" - ], - "title": "ExecOneOffCommandRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ExecOneOffCommandParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "ExecOneOffCommandRequest", - "type": "object" } ], "title": "ClientRequest" diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 518d650208..81140db5e9 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -3519,7 +3519,7 @@ "required": [ "state" ], - "title": "StateMcpStartupStatus", + "title": "StartingMcpStartupStatus", "type": "object" }, { @@ -3534,7 +3534,7 @@ "required": [ "state" ], - "title": "StateMcpStartupStatus2", + "title": "ReadyMcpStartupStatus", "type": "object" }, { @@ -3567,7 +3567,7 @@ "required": [ "state" ], - "title": "StateMcpStartupStatus3", + "title": "CancelledMcpStartupStatus", "type": "object" } ] diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index bcdbdb33ed..b2dc4c3c3e 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1,10 +1,6 @@ { "$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" - }, "AccountLoginCompletedNotification": { "properties": { "error": { @@ -54,30 +50,6 @@ }, "type": "object" }, - "AgentMessageContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Text" - ], - "title": "TextAgentMessageContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextAgentMessageContent", - "type": "object" - } - ] - }, "AgentMessageDeltaNotification": { "properties": { "delta": { @@ -101,70 +73,6 @@ ], "type": "object" }, - "AgentStatus": { - "description": "Agent lifecycle status, derived from emitted events.", - "oneOf": [ - { - "description": "Agent is waiting for initialization.", - "enum": [ - "pending_init" - ], - "type": "string" - }, - { - "description": "Agent is currently running.", - "enum": [ - "running" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Agent is done. Contains the final assistant message.", - "properties": { - "completed": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "completed" - ], - "title": "CompletedAgentStatus", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Agent encountered an error.", - "properties": { - "errored": { - "type": "string" - } - }, - "required": [ - "errored" - ], - "title": "ErroredAgentStatus", - "type": "object" - }, - { - "description": "Agent has been shutdown.", - "enum": [ - "shutdown" - ], - "type": "string" - }, - { - "description": "Agent is not found.", - "enum": [ - "not_found" - ], - "type": "string" - } - ] - }, "AppBranding": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { @@ -430,53 +338,6 @@ ], "type": "object" }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, "AuthMode": { "description": "Authentication mode for OpenAI-backed providers.", "oneOf": [ @@ -503,22 +364,6 @@ } ] }, - "AuthStatusChangeNotification": { - "description": "Deprecated notification. Use AccountUpdatedNotification instead.", - "properties": { - "authMethod": { - "anyOf": [ - { - "$ref": "#/definitions/AuthMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "ByteRange": { "properties": { "end": { @@ -538,48 +383,6 @@ ], "type": "object" }, - "ByteRange2": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "CallToolResult": { - "description": "The server's response to a tool call.", - "properties": { - "_meta": true, - "content": { - "items": true, - "type": "array" - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true - }, - "required": [ - "content" - ], - "type": "object" - }, "CodexErrorInfo": { "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", "oneOf": [ @@ -694,150 +497,6 @@ } ] }, - "CodexErrorInfo2": { - "description": "Codex errors that we expose to clients.", - "oneOf": [ - { - "enum": [ - "context_window_exceeded", - "usage_limit_exceeded", - "server_overloaded", - "internal_server_error", - "unauthorized", - "bad_request", - "sandbox_error", - "thread_rollback_failed", - "other" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "http_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "http_connection_failed" - ], - "title": "HttpConnectionFailedCodexErrorInfo2", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Failed to connect to the response SSE stream.", - "properties": { - "response_stream_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_connection_failed" - ], - "title": "ResponseStreamConnectionFailedCodexErrorInfo2", - "type": "object" - }, - { - "additionalProperties": false, - "description": "The response SSE stream disconnected in the middle of a turnbefore completion.", - "properties": { - "response_stream_disconnected": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_disconnected" - ], - "title": "ResponseStreamDisconnectedCodexErrorInfo2", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Reached the retry limit for responses.", - "properties": { - "response_too_many_failed_attempts": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_too_many_failed_attempts" - ], - "title": "ResponseTooManyFailedAttemptsCodexErrorInfo2", - "type": "object" - } - ] - }, - "CollabAgentRef": { - "properties": { - "agent_nickname": { - "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "agent_role": { - "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver/new agent." - } - }, - "required": [ - "thread_id" - ], - "type": "object" - }, "CollabAgentState": { "properties": { "message": { @@ -866,45 +525,6 @@ ], "type": "string" }, - "CollabAgentStatusEntry": { - "properties": { - "agent_nickname": { - "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "agent_role": { - "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the agent." - }, - "thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver/new agent." - } - }, - "required": [ - "status", - "thread_id" - ], - "type": "object" - }, "CollabAgentTool": { "enum": [ "spawnAgent", @@ -1102,70 +722,6 @@ ], "type": "object" }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, "ContextCompactedNotification": { "description": "Deprecated: Use `ContextCompaction` item type instead.", "properties": { @@ -1203,58 +759,6 @@ ], "type": "object" }, - "CreditsSnapshot2": { - "properties": { - "balance": { - "type": [ - "string", - "null" - ] - }, - "has_credits": { - "type": "boolean" - }, - "unlimited": { - "type": "boolean" - } - }, - "required": [ - "has_credits", - "unlimited" - ], - "type": "object" - }, - "CustomPrompt": { - "properties": { - "argument_hint": { - "type": [ - "string", - "null" - ] - }, - "content": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "content", - "name", - "path" - ], - "type": "object" - }, "DeprecationNoticeNotification": { "properties": { "details": { @@ -1274,25 +778,6 @@ ], "type": "object" }, - "Duration": { - "properties": { - "nanos": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "secs": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "nanos", - "secs" - ], - "type": "object" - }, "ErrorNotification": { "properties": { "error": { @@ -1316,2774 +801,6 @@ ], "type": "object" }, - "EventMsg": { - "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", - "oneOf": [ - { - "description": "Error while executing a submission", - "properties": { - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo2" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "error" - ], - "title": "ErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "ErrorEventMsg", - "type": "object" - }, - { - "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "warning" - ], - "title": "WarningEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "WarningEventMsg", - "type": "object" - }, - { - "description": "Realtime conversation lifecycle start event.", - "properties": { - "session_id": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "realtime_conversation_started" - ], - "title": "RealtimeConversationStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RealtimeConversationStartedEventMsg", - "type": "object" - }, - { - "description": "Realtime conversation streaming payload event.", - "properties": { - "payload": { - "$ref": "#/definitions/RealtimeEvent" - }, - "type": { - "enum": [ - "realtime_conversation_realtime" - ], - "title": "RealtimeConversationRealtimeEventMsgType", - "type": "string" - } - }, - "required": [ - "payload", - "type" - ], - "title": "RealtimeConversationRealtimeEventMsg", - "type": "object" - }, - { - "description": "Realtime conversation lifecycle close event.", - "properties": { - "reason": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "realtime_conversation_closed" - ], - "title": "RealtimeConversationClosedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RealtimeConversationClosedEventMsg", - "type": "object" - }, - { - "description": "Model routing changed from the requested model to a different model.", - "properties": { - "from_model": { - "type": "string" - }, - "reason": { - "$ref": "#/definitions/ModelRerouteReason2" - }, - "to_model": { - "type": "string" - }, - "type": { - "enum": [ - "model_reroute" - ], - "title": "ModelRerouteEventMsgType", - "type": "string" - } - }, - "required": [ - "from_model", - "reason", - "to_model", - "type" - ], - "title": "ModelRerouteEventMsg", - "type": "object" - }, - { - "description": "Conversation history was compacted (either automatically or manually).", - "properties": { - "type": { - "enum": [ - "context_compacted" - ], - "title": "ContextCompactedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactedEventMsg", - "type": "object" - }, - { - "description": "Conversation history was rolled back by dropping the last N user turns.", - "properties": { - "num_turns": { - "description": "Number of user turns that were removed from context.", - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "thread_rolled_back" - ], - "title": "ThreadRolledBackEventMsgType", - "type": "string" - } - }, - "required": [ - "num_turns", - "type" - ], - "title": "ThreadRolledBackEventMsg", - "type": "object" - }, - { - "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", - "properties": { - "collaboration_mode_kind": { - "allOf": [ - { - "$ref": "#/definitions/ModeKind" - } - ], - "default": "default" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "task_started" - ], - "title": "TaskStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "turn_id", - "type" - ], - "title": "TaskStartedEventMsg", - "type": "object" - }, - { - "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", - "properties": { - "last_agent_message": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "task_complete" - ], - "title": "TaskCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "turn_id", - "type" - ], - "title": "TaskCompleteEventMsg", - "type": "object" - }, - { - "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", - "properties": { - "info": { - "anyOf": [ - { - "$ref": "#/definitions/TokenUsageInfo" - }, - { - "type": "null" - } - ] - }, - "rate_limits": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitSnapshot2" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "token_count" - ], - "title": "TokenCountEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TokenCountEventMsg", - "type": "object" - }, - { - "description": "Agent text output message", - "properties": { - "message": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "default": null - }, - "type": { - "enum": [ - "agent_message" - ], - "title": "AgentMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "AgentMessageEventMsg", - "type": "object" - }, - { - "description": "User/system input message (what was sent to the model)", - "properties": { - "images": { - "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "local_images": { - "default": [], - "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", - "items": { - "type": "string" - }, - "type": "array" - }, - "message": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `message` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/TextElement2" - }, - "type": "array" - }, - "type": { - "enum": [ - "user_message" - ], - "title": "UserMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "UserMessageEventMsg", - "type": "object" - }, - { - "description": "Agent text output delta message", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_delta" - ], - "title": "AgentMessageDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentMessageDeltaEventMsg", - "type": "object" - }, - { - "description": "Reasoning event from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning" - ], - "title": "AgentReasoningEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_delta" - ], - "title": "AgentReasoningDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningDeltaEventMsg", - "type": "object" - }, - { - "description": "Raw chain-of-thought from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content" - ], - "title": "AgentReasoningRawContentEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningRawContentEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning content delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content_delta" - ], - "title": "AgentReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", - "properties": { - "item_id": { - "default": "", - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "type": { - "enum": [ - "agent_reasoning_section_break" - ], - "title": "AgentReasoningSectionBreakEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AgentReasoningSectionBreakEventMsg", - "type": "object" - }, - { - "description": "Ack the client's configure message.", - "properties": { - "approval_policy": { - "allOf": [ - { - "$ref": "#/definitions/AskForApproval" - } - ], - "description": "When to escalate for approval for execution" - }, - "cwd": { - "description": "Working directory that should be treated as the *root* of the session.", - "type": "string" - }, - "forked_from_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history_entry_count": { - "description": "Current number of entries in the history log.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "history_log_id": { - "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initial_messages": { - "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "description": "Tell the client what model is being queried.", - "type": "string" - }, - "model_provider_id": { - "type": "string" - }, - "network_proxy": { - "anyOf": [ - { - "$ref": "#/definitions/SessionNetworkProxyRuntime" - }, - { - "type": "null" - } - ], - "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." - }, - "reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ], - "description": "The effort the model is putting into reasoning about the user's request." - }, - "rollout_path": { - "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", - "type": [ - "string", - "null" - ] - }, - "sandbox_policy": { - "allOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - } - ], - "description": "How to sandbox commands executed in the system" - }, - "session_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "description": "Optional user-facing thread name (may be unset).", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "session_configured" - ], - "title": "SessionConfiguredEventMsgType", - "type": "string" - } - }, - "required": [ - "approval_policy", - "cwd", - "history_entry_count", - "history_log_id", - "model", - "model_provider_id", - "sandbox_policy", - "session_id", - "type" - ], - "title": "SessionConfiguredEventMsg", - "type": "object" - }, - { - "description": "Updated session metadata (e.g., thread name changes).", - "properties": { - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "thread_name_updated" - ], - "title": "ThreadNameUpdatedEventMsgType", - "type": "string" - } - }, - "required": [ - "thread_id", - "type" - ], - "title": "ThreadNameUpdatedEventMsg", - "type": "object" - }, - { - "description": "Incremental MCP startup progress updates.", - "properties": { - "server": { - "description": "Server name being started.", - "type": "string" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/McpStartupStatus" - } - ], - "description": "Current startup status." - }, - "type": { - "enum": [ - "mcp_startup_update" - ], - "title": "McpStartupUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "server", - "status", - "type" - ], - "title": "McpStartupUpdateEventMsg", - "type": "object" - }, - { - "description": "Aggregate MCP startup completion summary.", - "properties": { - "cancelled": { - "items": { - "type": "string" - }, - "type": "array" - }, - "failed": { - "items": { - "$ref": "#/definitions/McpStartupFailure" - }, - "type": "array" - }, - "ready": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "mcp_startup_complete" - ], - "title": "McpStartupCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "cancelled", - "failed", - "ready", - "type" - ], - "title": "McpStartupCompleteEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the McpToolCallEnd event.", - "type": "string" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "type": { - "enum": [ - "mcp_tool_call_begin" - ], - "title": "McpToolCallBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "invocation", - "type" - ], - "title": "McpToolCallBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier for the corresponding McpToolCallBegin that finished.", - "type": "string" - }, - "duration": { - "$ref": "#/definitions/Duration" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "result": { - "allOf": [ - { - "$ref": "#/definitions/Result_of_CallToolResult_or_String" - } - ], - "description": "Result of the tool call. Note this could be an error." - }, - "type": { - "enum": [ - "mcp_tool_call_end" - ], - "title": "McpToolCallEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "duration", - "invocation", - "result", - "type" - ], - "title": "McpToolCallEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_begin" - ], - "title": "WebSearchBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "WebSearchBeginEventMsg", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction2" - }, - "call_id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_end" - ], - "title": "WebSearchEndEventMsgType", - "type": "string" - } - }, - "required": [ - "action", - "call_id", - "query", - "type" - ], - "title": "WebSearchEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the server is about to execute a command.", - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the ExecCommandEnd event.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_begin" - ], - "title": "ExecCommandBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "turn_id", - "type" - ], - "title": "ExecCommandBeginEventMsg", - "type": "object" - }, - { - "description": "Incremental chunk of output from a running command.", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "chunk": { - "description": "Raw bytes from the stream (may not be valid UTF-8).", - "type": "string" - }, - "stream": { - "allOf": [ - { - "$ref": "#/definitions/ExecOutputStream" - } - ], - "description": "Which stream produced this chunk." - }, - "type": { - "enum": [ - "exec_command_output_delta" - ], - "title": "ExecCommandOutputDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "chunk", - "stream", - "type" - ], - "title": "ExecCommandOutputDeltaEventMsg", - "type": "object" - }, - { - "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "process_id": { - "description": "Process id associated with the running command.", - "type": "string" - }, - "stdin": { - "description": "Stdin sent to the running session.", - "type": "string" - }, - "type": { - "enum": [ - "terminal_interaction" - ], - "title": "TerminalInteractionEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "process_id", - "stdin", - "type" - ], - "title": "TerminalInteractionEventMsg", - "type": "object" - }, - { - "properties": { - "aggregated_output": { - "default": "", - "description": "Captured aggregated output", - "type": "string" - }, - "call_id": { - "description": "Identifier for the ExecCommandBegin that finished.", - "type": "string" - }, - "command": { - "description": "The command that was executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "duration": { - "allOf": [ - { - "$ref": "#/definitions/Duration" - } - ], - "description": "The duration of the command execution." - }, - "exit_code": { - "description": "The command's exit code.", - "format": "int32", - "type": "integer" - }, - "formatted_output": { - "description": "Formatted output from the command, as seen by the model.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandStatus" - } - ], - "description": "Completion status for this command execution." - }, - "stderr": { - "description": "Captured stderr", - "type": "string" - }, - "stdout": { - "description": "Captured stdout", - "type": "string" - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_end" - ], - "title": "ExecCommandEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "duration", - "exit_code", - "formatted_output", - "parsed_cmd", - "status", - "stderr", - "stdout", - "turn_id", - "type" - ], - "title": "ExecCommandEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent attached a local image via the view_image tool.", - "properties": { - "call_id": { - "description": "Identifier for the originating tool call.", - "type": "string" - }, - "path": { - "description": "Local filesystem path provided to the tool.", - "type": "string" - }, - "type": { - "enum": [ - "view_image_tool_call" - ], - "title": "ViewImageToolCallEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "path", - "type" - ], - "title": "ViewImageToolCallEventMsg", - "type": "object" - }, - { - "properties": { - "approval_id": { - "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", - "type": [ - "string", - "null" - ] - }, - "call_id": { - "description": "Identifier for the associated command execution item.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory.", - "type": "string" - }, - "network_approval_context": { - "anyOf": [ - { - "$ref": "#/definitions/NetworkApprovalContext" - }, - { - "type": "null" - } - ], - "description": "Optional network context for a blocked request that can be approved." - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "proposed_execpolicy_amendment": { - "description": "Proposed execpolicy amendment that can be applied to allow future runs.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "reason": { - "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "exec_approval_request" - ], - "title": "ExecApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "type" - ], - "title": "ExecApprovalRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated tool call, if available.", - "type": "string" - }, - "questions": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestion" - }, - "type": "array" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "request_user_input" - ], - "title": "RequestUserInputEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "questions", - "type" - ], - "title": "RequestUserInputEventMsg", - "type": "object" - }, - { - "properties": { - "arguments": true, - "callId": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "turnId": { - "type": "string" - }, - "type": { - "enum": [ - "dynamic_tool_call_request" - ], - "title": "DynamicToolCallRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "arguments", - "callId", - "tool", - "turnId", - "type" - ], - "title": "DynamicToolCallRequestEventMsg", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "message": { - "type": "string" - }, - "server_name": { - "type": "string" - }, - "type": { - "enum": [ - "elicitation_request" - ], - "title": "ElicitationRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "message", - "server_name", - "type" - ], - "title": "ElicitationRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated patch apply call, if available.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "type": "object" - }, - "grant_root": { - "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", - "type": [ - "string", - "null" - ] - }, - "reason": { - "description": "Optional explanatory reason (e.g. request for extra write access).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", - "type": "string" - }, - "type": { - "enum": [ - "apply_patch_approval_request" - ], - "title": "ApplyPatchApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "changes", - "type" - ], - "title": "ApplyPatchApprovalRequestEventMsg", - "type": "object" - }, - { - "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", - "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", - "type": [ - "string", - "null" - ] - }, - "summary": { - "description": "Concise summary of what is deprecated.", - "type": "string" - }, - "type": { - "enum": [ - "deprecation_notice" - ], - "title": "DeprecationNoticeEventMsgType", - "type": "string" - } - }, - "required": [ - "summary", - "type" - ], - "title": "DeprecationNoticeEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "background_event" - ], - "title": "BackgroundEventEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "BackgroundEventEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "undo_started" - ], - "title": "UndoStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UndoStartedEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "success": { - "type": "boolean" - }, - "type": { - "enum": [ - "undo_completed" - ], - "title": "UndoCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "success", - "type" - ], - "title": "UndoCompletedEventMsg", - "type": "object" - }, - { - "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", - "properties": { - "additional_details": { - "default": null, - "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", - "type": [ - "string", - "null" - ] - }, - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo2" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "stream_error" - ], - "title": "StreamErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "StreamErrorEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", - "properties": { - "auto_approved": { - "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", - "type": "boolean" - }, - "call_id": { - "description": "Identifier so this can be paired with the PatchApplyEnd event.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "description": "The changes to be applied.", - "type": "object" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_begin" - ], - "title": "PatchApplyBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "auto_approved", - "call_id", - "changes", - "type" - ], - "title": "PatchApplyBeginEventMsg", - "type": "object" - }, - { - "description": "Notification that a patch application has finished.", - "properties": { - "call_id": { - "description": "Identifier for the PatchApplyBegin that finished.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "default": {}, - "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", - "type": "object" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/PatchApplyStatus2" - } - ], - "description": "Completion status for this patch application." - }, - "stderr": { - "description": "Captured stderr (parser errors, IO failures, etc.).", - "type": "string" - }, - "stdout": { - "description": "Captured stdout (summary printed by apply_patch).", - "type": "string" - }, - "success": { - "description": "Whether the patch was applied successfully.", - "type": "boolean" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_end" - ], - "title": "PatchApplyEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "status", - "stderr", - "stdout", - "success", - "type" - ], - "title": "PatchApplyEndEventMsg", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "turn_diff" - ], - "title": "TurnDiffEventMsgType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "TurnDiffEventMsg", - "type": "object" - }, - { - "description": "Response to GetHistoryEntryRequest.", - "properties": { - "entry": { - "anyOf": [ - { - "$ref": "#/definitions/HistoryEntry" - }, - { - "type": "null" - } - ], - "description": "The entry at the requested offset, if available and parseable." - }, - "log_id": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "offset": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "get_history_entry_response" - ], - "title": "GetHistoryEntryResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "log_id", - "offset", - "type" - ], - "title": "GetHistoryEntryResponseEventMsg", - "type": "object" - }, - { - "description": "List of MCP tools available to the agent.", - "properties": { - "auth_statuses": { - "additionalProperties": { - "$ref": "#/definitions/McpAuthStatus" - }, - "description": "Authentication status for each configured MCP server.", - "type": "object" - }, - "resource_templates": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/ResourceTemplate" - }, - "type": "array" - }, - "description": "Known resource templates grouped by server name.", - "type": "object" - }, - "resources": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/Resource" - }, - "type": "array" - }, - "description": "Known resources grouped by server name.", - "type": "object" - }, - "tools": { - "additionalProperties": { - "$ref": "#/definitions/Tool" - }, - "description": "Fully qualified tool name -> tool definition.", - "type": "object" - }, - "type": { - "enum": [ - "mcp_list_tools_response" - ], - "title": "McpListToolsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "auth_statuses", - "resource_templates", - "resources", - "tools", - "type" - ], - "title": "McpListToolsResponseEventMsg", - "type": "object" - }, - { - "description": "List of custom prompts available to the agent.", - "properties": { - "custom_prompts": { - "items": { - "$ref": "#/definitions/CustomPrompt" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_custom_prompts_response" - ], - "title": "ListCustomPromptsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "custom_prompts", - "type" - ], - "title": "ListCustomPromptsResponseEventMsg", - "type": "object" - }, - { - "description": "List of skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/SkillsListEntry" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_skills_response" - ], - "title": "ListSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "List of remote skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_remote_skills_response" - ], - "title": "ListRemoteSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListRemoteSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "Remote skill downloaded to local cache.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "remote_skill_downloaded" - ], - "title": "RemoteSkillDownloadedEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "name", - "path", - "type" - ], - "title": "RemoteSkillDownloadedEventMsg", - "type": "object" - }, - { - "description": "Notification that skill data may have been updated and clients may want to reload.", - "properties": { - "type": { - "enum": [ - "skills_update_available" - ], - "title": "SkillsUpdateAvailableEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SkillsUpdateAvailableEventMsg", - "type": "object" - }, - { - "properties": { - "explanation": { - "default": null, - "description": "Arguments for the `update_plan` todo/checklist tool (not plan mode).", - "type": [ - "string", - "null" - ] - }, - "plan": { - "items": { - "$ref": "#/definitions/PlanItemArg" - }, - "type": "array" - }, - "type": { - "enum": [ - "plan_update" - ], - "title": "PlanUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "plan", - "type" - ], - "title": "PlanUpdateEventMsg", - "type": "object" - }, - { - "properties": { - "reason": { - "$ref": "#/definitions/TurnAbortReason" - }, - "turn_id": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "turn_aborted" - ], - "title": "TurnAbortedEventMsgType", - "type": "string" - } - }, - "required": [ - "reason", - "type" - ], - "title": "TurnAbortedEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is shutting down.", - "properties": { - "type": { - "enum": [ - "shutdown_complete" - ], - "title": "ShutdownCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ShutdownCompleteEventMsg", - "type": "object" - }, - { - "description": "Entered review mode.", - "properties": { - "target": { - "$ref": "#/definitions/ReviewTarget" - }, - "type": { - "enum": [ - "entered_review_mode" - ], - "title": "EnteredReviewModeEventMsgType", - "type": "string" - }, - "user_facing_hint": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "target", - "type" - ], - "title": "EnteredReviewModeEventMsg", - "type": "object" - }, - { - "description": "Exited review mode with an optional final result to apply.", - "properties": { - "review_output": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewOutputEvent" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "exited_review_mode" - ], - "title": "ExitedReviewModeEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExitedReviewModeEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/ResponseItem" - }, - "type": { - "enum": [ - "raw_response_item" - ], - "title": "RawResponseItemEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "type" - ], - "title": "RawResponseItemEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_started" - ], - "title": "ItemStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemStartedEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_completed" - ], - "title": "ItemCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemCompletedEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_content_delta" - ], - "title": "AgentMessageContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "AgentMessageContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "plan_delta" - ], - "title": "PlanDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "PlanDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_content_delta" - ], - "title": "ReasoningContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "content_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_raw_content_delta" - ], - "title": "ReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_spawn_begin" - ], - "title": "CollabAgentSpawnBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "type" - ], - "title": "CollabAgentSpawnBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "new_agent_nickname": { - "description": "Optional nickname assigned to the new agent.", - "type": [ - "string", - "null" - ] - }, - "new_agent_role": { - "description": "Optional role assigned to the new agent.", - "type": [ - "string", - "null" - ] - }, - "new_thread_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ], - "description": "Thread ID of the newly spawned agent, if it was created." - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the new agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_spawn_end" - ], - "title": "CollabAgentSpawnEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentSpawnEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_interaction_begin" - ], - "title": "CollabAgentInteractionBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabAgentInteractionBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_interaction_end" - ], - "title": "CollabAgentInteractionEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentInteractionEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting begin.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "receiver_agents": { - "description": "Optional nicknames/roles for receivers.", - "items": { - "$ref": "#/definitions/CollabAgentRef" - }, - "type": "array" - }, - "receiver_thread_ids": { - "description": "Thread ID of the receivers.", - "items": { - "$ref": "#/definitions/ThreadId" - }, - "type": "array" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_waiting_begin" - ], - "title": "CollabWaitingBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_ids", - "sender_thread_id", - "type" - ], - "title": "CollabWaitingBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting end.", - "properties": { - "agent_statuses": { - "description": "Optional receiver metadata paired with final statuses.", - "items": { - "$ref": "#/definitions/CollabAgentStatusEntry" - }, - "type": "array" - }, - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "statuses": { - "additionalProperties": { - "$ref": "#/definitions/AgentStatus" - }, - "description": "Last known status of the receiver agents reported to the sender agent.", - "type": "object" - }, - "type": { - "enum": [ - "collab_waiting_end" - ], - "title": "CollabWaitingEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "sender_thread_id", - "statuses", - "type" - ], - "title": "CollabWaitingEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_close_begin" - ], - "title": "CollabCloseBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabCloseBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent before the close." - }, - "type": { - "enum": [ - "collab_close_end" - ], - "title": "CollabCloseEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabCloseEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_resume_begin" - ], - "title": "CollabResumeBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabResumeBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent after resume." - }, - "type": { - "enum": [ - "collab_resume_end" - ], - "title": "CollabResumeEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabResumeEndEventMsg", - "type": "object" - } - ] - }, - "ExecCommandSource": { - "enum": [ - "agent", - "user_shell", - "unified_exec_startup", - "unified_exec_interaction" - ], - "type": "string" - }, - "ExecCommandStatus": { - "enum": [ - "completed", - "failed", - "declined" - ], - "type": "string" - }, - "ExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" - }, - "FileChange": { - "oneOf": [ - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "add" - ], - "title": "AddFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "AddFileChange", - "type": "object" - }, - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "delete" - ], - "title": "DeleteFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "DeleteFileChange", - "type": "object" - }, - { - "properties": { - "move_path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "update" - ], - "title": "UpdateFileChangeType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "UpdateFileChange", - "type": "object" - } - ] - }, "FileChangeOutputDeltaNotification": { "properties": { "delta": { @@ -4126,82 +843,6 @@ ], "type": "object" }, - "FunctionCallOutputBody": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" - }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, "FuzzyFileSearchResult": { "description": "Superset of [`codex_file_search::FileMatch`]", "properties": { @@ -4272,38 +913,6 @@ ], "type": "object" }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, "GitInfo": { "properties": { "branch": { @@ -4327,27 +936,6 @@ }, "type": "object" }, - "HistoryEntry": { - "properties": { - "conversation_id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "ts": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "conversation_id", - "text", - "ts" - ], - "type": "object" - }, "ItemCompletedNotification": { "properties": { "item": { @@ -4386,121 +974,6 @@ ], "type": "object" }, - "LocalShellAction": { - "oneOf": [ - { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "exec" - ], - "title": "ExecLocalShellActionType", - "type": "string" - }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "command", - "type" - ], - "title": "ExecLocalShellAction", - "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "LoginChatGptCompleteNotification": { - "description": "Deprecated in favor of AccountLoginCompletedNotification.", - "properties": { - "error": { - "type": [ - "string", - "null" - ] - }, - "loginId": { - "type": "string" - }, - "success": { - "type": "boolean" - } - }, - "required": [ - "loginId", - "success" - ], - "type": "object" - }, - "McpAuthStatus": { - "enum": [ - "unsupported", - "not_logged_in", - "bearer_token", - "o_auth" - ], - "type": "string" - }, - "McpInvocation": { - "properties": { - "arguments": { - "description": "Arguments to the tool call." - }, - "server": { - "description": "Name of the MCP server as defined in the config.", - "type": "string" - }, - "tool": { - "description": "Name of the tool as given by the MCP server.", - "type": "string" - } - }, - "required": [ - "server", - "tool" - ], - "type": "object" - }, "McpServerOauthLoginCompletedNotification": { "properties": { "error": { @@ -4522,88 +995,6 @@ ], "type": "object" }, - "McpStartupFailure": { - "properties": { - "error": { - "type": "string" - }, - "server": { - "type": "string" - } - }, - "required": [ - "error", - "server" - ], - "type": "object" - }, - "McpStartupStatus": { - "oneOf": [ - { - "properties": { - "state": { - "enum": [ - "starting" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus", - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "ready" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus2", - "type": "object" - }, - { - "properties": { - "error": { - "type": "string" - }, - "state": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "error", - "state" - ], - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "cancelled" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus3", - "type": "object" - } - ] - }, "McpToolCallError": { "properties": { "message": { @@ -4678,26 +1069,12 @@ } ] }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" - ], - "type": "string" - }, "ModelRerouteReason": { "enum": [ "highRiskCyberActivity" ], "type": "string" }, - "ModelRerouteReason2": { - "enum": [ - "high_risk_cyber_activity" - ], - "type": "string" - }, "ModelReroutedNotification": { "properties": { "fromModel": { @@ -4725,149 +1102,6 @@ ], "type": "object" }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "NetworkApprovalContext": { - "properties": { - "host": { - "type": "string" - }, - "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" - } - }, - "required": [ - "host", - "protocol" - ], - "type": "object" - }, - "NetworkApprovalProtocol": { - "enum": [ - "http", - "https", - "socks5_tcp", - "socks5_udp" - ], - "type": "string" - }, - "ParsedCommand": { - "oneOf": [ - { - "properties": { - "cmd": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", - "type": "string" - }, - "type": { - "enum": [ - "read" - ], - "title": "ReadParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "name", - "path", - "type" - ], - "title": "ReadParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "list_files" - ], - "title": "ListFilesParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ListFilesParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "SearchParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "unknown" - ], - "title": "UnknownParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "UnknownParsedCommand", - "type": "object" - } - ] - }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -4877,14 +1111,6 @@ ], "type": "string" }, - "PatchApplyStatus2": { - "enum": [ - "completed", - "failed", - "declined" - ], - "type": "string" - }, "PatchChangeKind": { "oneOf": [ { @@ -4967,22 +1193,6 @@ ], "type": "object" }, - "PlanItemArg": { - "additionalProperties": false, - "properties": { - "status": { - "$ref": "#/definitions/StepStatus" - }, - "step": { - "type": "string" - } - }, - "required": [ - "status", - "step" - ], - "type": "object" - }, "PlanType": { "enum": [ "free", @@ -5054,63 +1264,6 @@ }, "type": "object" }, - "RateLimitSnapshot2": { - "properties": { - "credits": { - "anyOf": [ - { - "$ref": "#/definitions/CreditsSnapshot2" - }, - { - "type": "null" - } - ] - }, - "limit_id": { - "type": [ - "string", - "null" - ] - }, - "limit_name": { - "type": [ - "string", - "null" - ] - }, - "plan_type": { - "anyOf": [ - { - "$ref": "#/definitions/PlanType" - }, - { - "type": "null" - } - ] - }, - "primary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow2" - }, - { - "type": "null" - } - ] - }, - "secondary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow2" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "RateLimitWindow": { "properties": { "resetsAt": { @@ -5137,299 +1290,6 @@ ], "type": "object" }, - "RateLimitWindow2": { - "properties": { - "resets_at": { - "description": "Unix timestamp (seconds since epoch) when the window resets.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "used_percent": { - "description": "Percentage (0-100) of the window that has been consumed.", - "format": "double", - "type": "number" - }, - "window_minutes": { - "description": "Rolling window duration, in minutes.", - "format": "int64", - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "used_percent" - ], - "type": "object" - }, - "RawResponseItemCompletedNotification": { - "properties": { - "item": { - "$ref": "#/definitions/ResponseItem" - }, - "threadId": { - "type": "string" - }, - "turnId": { - "type": "string" - } - }, - "required": [ - "item", - "threadId", - "turnId" - ], - "type": "object" - }, - "ReadOnlyAccess": { - "description": "Determines how read-only file access is granted inside a restricted sandbox.", - "oneOf": [ - { - "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", - "properties": { - "include_platform_defaults": { - "default": true, - "description": "Include built-in platform read roots required for basic process execution.", - "type": "boolean" - }, - "readable_roots": { - "description": "Additional absolute roots that should be readable.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "description": "Allow unrestricted file reads.", - "properties": { - "type": { - "enum": [ - "full-access" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, - "RealtimeAudioFrame": { - "properties": { - "data": { - "type": "string" - }, - "num_channels": { - "format": "uint16", - "minimum": 0.0, - "type": "integer" - }, - "sample_rate": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "samples_per_channel": { - "format": "uint32", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "data", - "num_channels", - "sample_rate" - ], - "type": "object" - }, - "RealtimeEvent": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "SessionCreated": { - "properties": { - "session_id": { - "type": "string" - } - }, - "required": [ - "session_id" - ], - "type": "object" - } - }, - "required": [ - "SessionCreated" - ], - "title": "SessionCreatedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "SessionUpdated": { - "properties": { - "backend_prompt": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "SessionUpdated" - ], - "title": "SessionUpdatedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "AudioOut": { - "$ref": "#/definitions/RealtimeAudioFrame" - } - }, - "required": [ - "AudioOut" - ], - "title": "AudioOutRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "ConversationItemAdded": true - }, - "required": [ - "ConversationItemAdded" - ], - "title": "ConversationItemAddedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "Error": { - "type": "string" - } - }, - "required": [ - "Error" - ], - "title": "ErrorRealtimeEvent", - "type": "object" - } - ] - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, "ReasoningSummaryPartAddedNotification": { "properties": { "itemId": { @@ -5510,953 +1370,6 @@ ], "type": "object" }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, - "RequestId": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "int64", - "type": "integer" - } - ], - "description": "ID of a request, which can be either a string or an integer." - }, - "RequestUserInputQuestion": { - "properties": { - "header": { - "type": "string" - }, - "id": { - "type": "string" - }, - "isOther": { - "default": false, - "type": "boolean" - }, - "isSecret": { - "default": false, - "type": "boolean" - }, - "options": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestionOption" - }, - "type": [ - "array", - "null" - ] - }, - "question": { - "type": "string" - } - }, - "required": [ - "header", - "id", - "question" - ], - "type": "object" - }, - "RequestUserInputQuestionOption": { - "properties": { - "description": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "description", - "label" - ], - "type": "object" - }, - "Resource": { - "description": "A known resource that the server is capable of reading.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "size": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "name", - "uri" - ], - "type": "object" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server.", - "properties": { - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "type": "string" - } - }, - "required": [ - "name", - "uriTemplate" - ], - "type": "object" - }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" - }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "type": "string" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction2" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction" - ], - "title": "CompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionResponseItem", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" - } - ] - }, - "Result_of_CallToolResult_or_String": { - "oneOf": [ - { - "properties": { - "Ok": { - "$ref": "#/definitions/CallToolResult" - } - }, - "required": [ - "Ok" - ], - "title": "OkResult_of_CallToolResult_or_String", - "type": "object" - }, - { - "properties": { - "Err": { - "type": "string" - } - }, - "required": [ - "Err" - ], - "title": "ErrResult_of_CallToolResult_or_String", - "type": "object" - } - ] - }, - "ReviewCodeLocation": { - "description": "Location of the code related to a review finding.", - "properties": { - "absolute_file_path": { - "type": "string" - }, - "line_range": { - "$ref": "#/definitions/ReviewLineRange" - } - }, - "required": [ - "absolute_file_path", - "line_range" - ], - "type": "object" - }, - "ReviewFinding": { - "description": "A single review finding describing an observed issue or recommendation.", - "properties": { - "body": { - "type": "string" - }, - "code_location": { - "$ref": "#/definitions/ReviewCodeLocation" - }, - "confidence_score": { - "format": "float", - "type": "number" - }, - "priority": { - "format": "int32", - "type": "integer" - }, - "title": { - "type": "string" - } - }, - "required": [ - "body", - "code_location", - "confidence_score", - "priority", - "title" - ], - "type": "object" - }, - "ReviewLineRange": { - "description": "Inclusive line range in a file associated with the finding.", - "properties": { - "end": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "ReviewOutputEvent": { - "description": "Structured review result produced by a child review session.", - "properties": { - "findings": { - "items": { - "$ref": "#/definitions/ReviewFinding" - }, - "type": "array" - }, - "overall_confidence_score": { - "format": "float", - "type": "number" - }, - "overall_correctness": { - "type": "string" - }, - "overall_explanation": { - "type": "string" - } - }, - "required": [ - "findings", - "overall_confidence_score", - "overall_correctness", - "overall_explanation" - ], - "type": "object" - }, - "ReviewTarget": { - "oneOf": [ - { - "description": "Review the working tree: staged, unstaged, and untracked files.", - "properties": { - "type": { - "enum": [ - "uncommittedChanges" - ], - "title": "UncommittedChangesReviewTargetType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UncommittedChangesReviewTarget", - "type": "object" - }, - { - "description": "Review changes between the current branch and the given base branch.", - "properties": { - "branch": { - "type": "string" - }, - "type": { - "enum": [ - "baseBranch" - ], - "title": "BaseBranchReviewTargetType", - "type": "string" - } - }, - "required": [ - "branch", - "type" - ], - "title": "BaseBranchReviewTarget", - "type": "object" - }, - { - "description": "Review the changes introduced by a specific commit.", - "properties": { - "sha": { - "type": "string" - }, - "title": { - "description": "Optional human-readable label (e.g., commit subject) for UIs.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "commit" - ], - "title": "CommitReviewTargetType", - "type": "string" - } - }, - "required": [ - "sha", - "type" - ], - "title": "CommitReviewTarget", - "type": "object" - }, - { - "description": "Arbitrary instructions provided by the user.", - "properties": { - "instructions": { - "type": "string" - }, - "type": { - "enum": [ - "custom" - ], - "title": "CustomReviewTargetType", - "type": "string" - } - }, - "required": [ - "instructions", - "type" - ], - "title": "CustomReviewTarget", - "type": "object" - } - ] - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access configuration.", - "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "read_only_access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - }, - "SessionConfiguredNotification": { - "properties": { - "historyEntryCount": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "historyLogId": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "rolloutPath": { - "type": "string" - }, - "sessionId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "historyEntryCount", - "historyLogId", - "model", - "rolloutPath", - "sessionId" - ], - "type": "object" - }, - "SessionNetworkProxyRuntime": { - "properties": { - "admin_addr": { - "type": "string" - }, - "http_addr": { - "type": "string" - }, - "socks_addr": { - "type": "string" - } - }, - "required": [ - "admin_addr", - "http_addr", - "socks_addr" - ], - "type": "object" - }, "SessionSource": { "oneOf": [ { @@ -6484,211 +1397,6 @@ } ] }, - "SkillDependencies": { - "properties": { - "tools": { - "items": { - "$ref": "#/definitions/SkillToolDependency" - }, - "type": "array" - } - }, - "required": [ - "tools" - ], - "type": "object" - }, - "SkillErrorInfo": { - "properties": { - "message": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "message", - "path" - ], - "type": "object" - }, - "SkillInterface": { - "properties": { - "brand_color": { - "type": [ - "string", - "null" - ] - }, - "default_prompt": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "icon_large": { - "type": [ - "string", - "null" - ] - }, - "icon_small": { - "type": [ - "string", - "null" - ] - }, - "short_description": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "SkillMetadata": { - "properties": { - "dependencies": { - "anyOf": [ - { - "$ref": "#/definitions/SkillDependencies" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "interface": { - "anyOf": [ - { - "$ref": "#/definitions/SkillInterface" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "scope": { - "$ref": "#/definitions/SkillScope" - }, - "short_description": { - "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "description", - "enabled", - "name", - "path", - "scope" - ], - "type": "object" - }, - "SkillScope": { - "enum": [ - "user", - "repo", - "system", - "admin" - ], - "type": "string" - }, - "SkillToolDependency": { - "properties": { - "command": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "transport": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "value" - ], - "type": "object" - }, - "SkillsListEntry": { - "properties": { - "cwd": { - "type": "string" - }, - "errors": { - "items": { - "$ref": "#/definitions/SkillErrorInfo" - }, - "type": "array" - }, - "skills": { - "items": { - "$ref": "#/definitions/SkillMetadata" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "errors", - "skills" - ], - "type": "object" - }, - "StepStatus": { - "enum": [ - "pending", - "in_progress", - "completed" - ], - "type": "string" - }, "SubAgentSource": { "oneOf": [ { @@ -6804,29 +1512,6 @@ ], "type": "object" }, - "TextElement2": { - "properties": { - "byte_range": { - "allOf": [ - { - "$ref": "#/definitions/ByteRange2" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byte_range" - ], - "type": "object" - }, "TextPosition": { "properties": { "column": { @@ -7645,38 +2330,6 @@ ], "type": "object" }, - "TokenUsage": { - "properties": { - "cached_input_tokens": { - "format": "int64", - "type": "integer" - }, - "input_tokens": { - "format": "int64", - "type": "integer" - }, - "output_tokens": { - "format": "int64", - "type": "integer" - }, - "reasoning_output_tokens": { - "format": "int64", - "type": "integer" - }, - "total_tokens": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "cached_input_tokens", - "input_tokens", - "output_tokens", - "reasoning_output_tokens", - "total_tokens" - ], - "type": "object" - }, "TokenUsageBreakdown": { "properties": { "cachedInputTokens": { @@ -7709,64 +2362,6 @@ ], "type": "object" }, - "TokenUsageInfo": { - "properties": { - "last_token_usage": { - "$ref": "#/definitions/TokenUsage" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "total_token_usage": { - "$ref": "#/definitions/TokenUsage" - } - }, - "required": [ - "last_token_usage", - "total_token_usage" - ], - "type": "object" - }, - "Tool": { - "description": "Definition for a tool the client can call.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "inputSchema": true, - "name": { - "type": "string" - }, - "outputSchema": true, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "inputSchema", - "name" - ], - "type": "object" - }, "Turn": { "properties": { "error": { @@ -7801,14 +2396,6 @@ ], "type": "object" }, - "TurnAbortReason": { - "enum": [ - "interrupted", - "replaced", - "review_ended" - ], - "type": "string" - }, "TurnCompletedNotification": { "properties": { "threadId": { @@ -7872,182 +2459,6 @@ ], "type": "object" }, - "TurnItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/UserInput2" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "type": { - "enum": [ - "UserMessage" - ], - "title": "UserMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "UserMessageTurnItem", - "type": "object" - }, - { - "description": "Assistant-authored message payload used in turn-item streams.\n\n`phase` is optional because not all providers/models emit it. Consumers should use it when present, but retain legacy completion semantics when it is `None`.", - "properties": { - "content": { - "items": { - "$ref": "#/definitions/AgentMessageContent" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." - }, - "type": { - "enum": [ - "AgentMessage" - ], - "title": "AgentMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "AgentMessageTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Plan" - ], - "title": "PlanTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "text", - "type" - ], - "title": "PlanTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "raw_content": { - "default": [], - "items": { - "type": "string" - }, - "type": "array" - }, - "summary_text": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "Reasoning" - ], - "title": "ReasoningTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary_text", - "type" - ], - "title": "ReasoningTurnItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction2" - }, - "id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "WebSearch" - ], - "title": "WebSearchTurnItemType", - "type": "string" - } - }, - "required": [ - "action", - "id", - "query", - "type" - ], - "title": "WebSearchTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "type": { - "enum": [ - "ContextCompaction" - ], - "title": "ContextCompactionTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ContextCompactionTurnItem", - "type": "object" - } - ] - }, "TurnPlanStep": { "properties": { "status": { @@ -8243,131 +2654,6 @@ } ] }, - "UserInput2": { - "description": "User input", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` that should be treated as special elements. These are byte ranges into the UTF-8 `text` buffer and are used to render or persist rich input markers (e.g., image placeholders) across history and resume without mutating the literal text.", - "items": { - "$ref": "#/definitions/TextElement2" - }, - "type": "array" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextUserInput2Type", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextUserInput2", - "type": "object" - }, - { - "description": "Pre‑encoded data: URI image.", - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageUserInput2Type", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "ImageUserInput2", - "type": "object" - }, - { - "description": "Local image path provided by the user. This will be converted to an `Image` variant (base64 data URL) during request serialization.", - "properties": { - "path": { - "type": "string" - }, - "type": { - "enum": [ - "local_image" - ], - "title": "LocalImageUserInput2Type", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "LocalImageUserInput2", - "type": "object" - }, - { - "description": "Skill selected by the user (name + path to SKILL.md).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "skill" - ], - "title": "SkillUserInput2Type", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "SkillUserInput2", - "type": "object" - }, - { - "description": "Explicit mention selected by the user (name + app://connector id).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "mention" - ], - "title": "MentionUserInput2Type", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "MentionUserInput2", - "type": "object" - } - ] - }, "WebSearchAction": { "oneOf": [ { @@ -8469,107 +2755,6 @@ } ] }, - "WebSearchAction2": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchAction2Type", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction2", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchAction2Type", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction2", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchAction2Type", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction2", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchAction2Type", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction2", - "type": "object" - } - ] - }, "WindowsSandboxSetupCompletedNotification": { "properties": { "error": { @@ -8886,27 +3071,6 @@ "title": "Item/completedNotification", "type": "object" }, - { - "description": "This event is internal-only. Used by Codex Cloud.", - "properties": { - "method": { - "enum": [ - "rawResponseItem/completed" - ], - "title": "RawResponseItem/completedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/RawResponseItemCompletedNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "RawResponseItem/completedNotification", - "type": "object" - }, { "properties": { "method": { @@ -9349,68 +3513,6 @@ ], "title": "Account/login/completedNotification", "type": "object" - }, - { - "description": "DEPRECATED NOTIFICATIONS below", - "properties": { - "method": { - "enum": [ - "authStatusChange" - ], - "title": "AuthStatusChangeNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/AuthStatusChangeNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "AuthStatusChangeNotification", - "type": "object" - }, - { - "description": "Deprecated: use `account/login/completed` instead.", - "properties": { - "method": { - "enum": [ - "loginChatGptComplete" - ], - "title": "LoginChatGptCompleteNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/LoginChatGptCompleteNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "LoginChatGptCompleteNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "sessionConfigured" - ], - "title": "SessionConfiguredNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SessionConfiguredNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "SessionConfiguredNotification", - "type": "object" } ], "title": "ServerNotification" 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 2c1eb40316..89f7dc34c3 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 @@ -1,40 +1,6 @@ { "$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" - }, - "AddConversationListenerParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "experimentalRawEvents": { - "default": false, - "type": "boolean" - } - }, - "required": [ - "conversationId" - ], - "title": "AddConversationListenerParams", - "type": "object" - }, - "AddConversationSubscriptionResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "subscriptionId": { - "type": "string" - } - }, - "required": [ - "subscriptionId" - ], - "title": "AddConversationSubscriptionResponse", - "type": "object" - }, "AgentMessageContent": { "oneOf": [ { @@ -175,140 +141,6 @@ "title": "ApplyPatchApprovalResponse", "type": "object" }, - "ArchiveConversationParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "rolloutPath" - ], - "title": "ArchiveConversationParams", - "type": "object" - }, - "ArchiveConversationResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ArchiveConversationResponse", - "type": "object" - }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "AuthMode": { - "description": "Authentication mode for OpenAI-backed providers.", - "oneOf": [ - { - "description": "OpenAI API key provided by the caller and stored by Codex.", - "enum": [ - "apikey" - ], - "type": "string" - }, - { - "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", - "enum": [ - "chatgpt" - ], - "type": "string" - }, - { - "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", - "enum": [ - "chatgptAuthTokens" - ], - "type": "string" - } - ] - }, - "AuthStatusChangeNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Deprecated notification. Use AccountUpdatedNotification instead.", - "properties": { - "authMethod": { - "anyOf": [ - { - "$ref": "#/definitions/AuthMode" - }, - { - "type": "null" - } - ] - } - }, - "title": "AuthStatusChangeNotification", - "type": "object" - }, - "ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, "CallToolResult": { "description": "The server's response to a tool call.", "properties": { @@ -330,24 +162,6 @@ ], "type": "object" }, - "CancelLoginChatGptParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "loginId": { - "type": "string" - } - }, - "required": [ - "loginId" - ], - "title": "CancelLoginChatGptParams", - "type": "object" - }, - "CancelLoginChatGptResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "CancelLoginChatGptResponse", - "type": "object" - }, "ChatgptAuthTokensRefreshParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -1359,510 +1173,6 @@ "title": "Account/readRequest", "type": "object" }, - { - "description": "DEPRECATED APIs below", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "newConversation" - ], - "title": "NewConversationRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/NewConversationParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "NewConversationRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "getConversationSummary" - ], - "title": "GetConversationSummaryRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/GetConversationSummaryParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "GetConversationSummaryRequest", - "type": "object" - }, - { - "description": "List recorded Codex conversations (rollouts) with optional pagination and search.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "listConversations" - ], - "title": "ListConversationsRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ListConversationsParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "ListConversationsRequest", - "type": "object" - }, - { - "description": "Resume a recorded Codex conversation from a rollout file.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "resumeConversation" - ], - "title": "ResumeConversationRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ResumeConversationParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "ResumeConversationRequest", - "type": "object" - }, - { - "description": "Fork a recorded Codex conversation into a new session.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "forkConversation" - ], - "title": "ForkConversationRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ForkConversationParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "ForkConversationRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "archiveConversation" - ], - "title": "ArchiveConversationRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ArchiveConversationParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "ArchiveConversationRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "sendUserMessage" - ], - "title": "SendUserMessageRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SendUserMessageParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "SendUserMessageRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "sendUserTurn" - ], - "title": "SendUserTurnRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SendUserTurnParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "SendUserTurnRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "interruptConversation" - ], - "title": "InterruptConversationRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/InterruptConversationParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "InterruptConversationRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "addConversationListener" - ], - "title": "AddConversationListenerRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/AddConversationListenerParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "AddConversationListenerRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "removeConversationListener" - ], - "title": "RemoveConversationListenerRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/RemoveConversationListenerParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "RemoveConversationListenerRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "gitDiffToRemote" - ], - "title": "GitDiffToRemoteRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/GitDiffToRemoteParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "GitDiffToRemoteRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "loginApiKey" - ], - "title": "LoginApiKeyRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/LoginApiKeyParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "LoginApiKeyRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "loginChatGpt" - ], - "title": "LoginChatGptRequestMethod", - "type": "string" - }, - "params": { - "type": "null" - } - }, - "required": [ - "id", - "method" - ], - "title": "LoginChatGptRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "cancelLoginChatGpt" - ], - "title": "CancelLoginChatGptRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/CancelLoginChatGptParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "CancelLoginChatGptRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "logoutChatGpt" - ], - "title": "LogoutChatGptRequestMethod", - "type": "string" - }, - "params": { - "type": "null" - } - }, - "required": [ - "id", - "method" - ], - "title": "LogoutChatGptRequest", - "type": "object" - }, - { - "description": "DEPRECATED in favor of GetAccount", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "getAuthStatus" - ], - "title": "GetAuthStatusRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/GetAuthStatusParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "GetAuthStatusRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "getUserSavedConfig" - ], - "title": "GetUserSavedConfigRequestMethod", - "type": "string" - }, - "params": { - "type": "null" - } - }, - "required": [ - "id", - "method" - ], - "title": "GetUserSavedConfigRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "setDefaultModel" - ], - "title": "SetDefaultModelRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SetDefaultModelParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "SetDefaultModelRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "getUserAgent" - ], - "title": "GetUserAgentRequestMethod", - "type": "string" - }, - "params": { - "type": "null" - } - }, - "required": [ - "id", - "method" - ], - "title": "GetUserAgentRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "userInfo" - ], - "title": "UserInfoRequestMethod", - "type": "string" - }, - "params": { - "type": "null" - } - }, - "required": [ - "id", - "method" - ], - "title": "UserInfoRequest", - "type": "object" - }, { "properties": { "id": { @@ -1886,149 +1196,10 @@ ], "title": "FuzzyFileSearchRequest", "type": "object" - }, - { - "description": "Execute a command (argv vector) under the server's sandbox.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "execOneOffCommand" - ], - "title": "ExecOneOffCommandRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ExecOneOffCommandParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "ExecOneOffCommandRequest", - "type": "object" } ], "title": "ClientRequest" }, - "CodexErrorInfo": { - "description": "Codex errors that we expose to clients.", - "oneOf": [ - { - "enum": [ - "context_window_exceeded", - "usage_limit_exceeded", - "server_overloaded", - "internal_server_error", - "unauthorized", - "bad_request", - "sandbox_error", - "thread_rollback_failed", - "other" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "http_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "http_connection_failed" - ], - "title": "HttpConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Failed to connect to the response SSE stream.", - "properties": { - "response_stream_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_connection_failed" - ], - "title": "ResponseStreamConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "The response SSE stream disconnected in the middle of a turnbefore completion.", - "properties": { - "response_stream_disconnected": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_disconnected" - ], - "title": "ResponseStreamDisconnectedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Reached the retry limit for responses.", - "properties": { - "response_too_many_failed_attempts": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_too_many_failed_attempts" - ], - "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", - "type": "object" - } - ] - }, "CollabAgentRef": { "properties": { "agent_nickname": { @@ -2248,171 +1419,6 @@ "title": "CommandExecutionRequestApprovalResponse", "type": "object" }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, - "ConversationGitInfo": { - "properties": { - "branch": { - "type": [ - "string", - "null" - ] - }, - "origin_url": { - "type": [ - "string", - "null" - ] - }, - "sha": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "ConversationSummary": { - "properties": { - "cliVersion": { - "type": "string" - }, - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "cwd": { - "type": "string" - }, - "gitInfo": { - "anyOf": [ - { - "$ref": "#/definitions/ConversationGitInfo" - }, - { - "type": "null" - } - ] - }, - "modelProvider": { - "type": "string" - }, - "path": { - "type": "string" - }, - "preview": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/SessionSource" - }, - "timestamp": { - "type": [ - "string", - "null" - ] - }, - "updatedAt": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "cliVersion", - "conversationId", - "cwd", - "modelProvider", - "path", - "preview", - "source" - ], - "type": "object" - }, - "CreditsSnapshot": { - "properties": { - "balance": { - "type": [ - "string", - "null" - ] - }, - "has_credits": { - "type": "boolean" - }, - "unlimited": { - "type": "boolean" - } - }, - "required": [ - "has_credits", - "unlimited" - ], - "type": "object" - }, "CustomPrompt": { "properties": { "argument_hint": { @@ -5309,68 +4315,6 @@ ], "type": "string" }, - "ExecOneOffCommandParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "sandboxPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - }, - { - "type": "null" - } - ] - }, - "timeoutMs": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "command" - ], - "title": "ExecOneOffCommandParams", - "type": "object" - }, - "ExecOneOffCommandResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "exitCode": { - "format": "int32", - "type": "integer" - }, - "stderr": { - "type": "string" - }, - "stdout": { - "type": "string" - } - }, - "required": [ - "exitCode", - "stderr", - "stdout" - ], - "title": "ExecOneOffCommandResponse", - "type": "object" - }, "ExecOutputStream": { "enum": [ "stdout", @@ -5528,152 +4472,6 @@ "title": "FileChangeRequestApprovalResponse", "type": "object" }, - "ForcedLoginMethod": { - "enum": [ - "chatgpt", - "api" - ], - "type": "string" - }, - "ForkConversationParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "overrides": { - "anyOf": [ - { - "$ref": "#/definitions/NewConversationParams" - }, - { - "type": "null" - } - ] - }, - "path": { - "type": [ - "string", - "null" - ] - } - }, - "title": "ForkConversationParams", - "type": "object" - }, - "ForkConversationResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "model", - "rolloutPath" - ], - "title": "ForkConversationResponse", - "type": "object" - }, - "FunctionCallOutputBody": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" - }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, "FuzzyFileSearchParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -5790,188 +4588,6 @@ "title": "FuzzyFileSearchSessionUpdatedNotification", "type": "object" }, - "GetAuthStatusParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "includeToken": { - "type": [ - "boolean", - "null" - ] - }, - "refreshToken": { - "type": [ - "boolean", - "null" - ] - } - }, - "title": "GetAuthStatusParams", - "type": "object" - }, - "GetAuthStatusResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "authMethod": { - "anyOf": [ - { - "$ref": "#/definitions/AuthMode" - }, - { - "type": "null" - } - ] - }, - "authToken": { - "type": [ - "string", - "null" - ] - }, - "requiresOpenaiAuth": { - "type": [ - "boolean", - "null" - ] - } - }, - "title": "GetAuthStatusResponse", - "type": "object" - }, - "GetConversationSummaryParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "anyOf": [ - { - "properties": { - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "rolloutPath" - ], - "title": "RolloutPathv1::GetConversationSummaryParams", - "type": "object" - }, - { - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "conversationId" - ], - "title": "ConversationIdv1::GetConversationSummaryParams", - "type": "object" - } - ], - "title": "GetConversationSummaryParams" - }, - "GetConversationSummaryResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "summary": { - "$ref": "#/definitions/ConversationSummary" - } - }, - "required": [ - "summary" - ], - "title": "GetConversationSummaryResponse", - "type": "object" - }, - "GetUserAgentResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "userAgent": { - "type": "string" - } - }, - "required": [ - "userAgent" - ], - "title": "GetUserAgentResponse", - "type": "object" - }, - "GetUserSavedConfigResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "config": { - "$ref": "#/definitions/UserSavedConfig" - } - }, - "required": [ - "config" - ], - "title": "GetUserSavedConfigResponse", - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, - "GitDiffToRemoteParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "cwd": { - "type": "string" - } - }, - "required": [ - "cwd" - ], - "title": "GitDiffToRemoteParams", - "type": "object" - }, - "GitDiffToRemoteResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "diff": { - "type": "string" - }, - "sha": { - "$ref": "#/definitions/GitSha" - } - }, - "required": [ - "diff", - "sha" - ], - "title": "GitDiffToRemoteResponse", - "type": "object" - }, - "GitSha": { - "type": "string" - }, "HistoryEntry": { "properties": { "conversation_id": { @@ -6050,128 +4666,6 @@ "title": "InitializeResponse", "type": "object" }, - "InputItem": { - "oneOf": [ - { - "properties": { - "data": { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/V1TextElement" - }, - "type": "array" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "TextInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "image_url": { - "type": "string" - } - }, - "required": [ - "image_url" - ], - "type": "object" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "ImageInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "path": { - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "type": { - "enum": [ - "localImage" - ], - "title": "LocalImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "LocalImageInputItem", - "type": "object" - } - ] - }, - "InterruptConversationParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "conversationId" - ], - "title": "InterruptConversationParams", - "type": "object" - }, - "InterruptConversationResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "abortReason": { - "$ref": "#/definitions/TurnAbortReason" - } - }, - "required": [ - "abortReason" - ], - "title": "InterruptConversationResponse", - "type": "object" - }, "JSONRPCError": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "A response to a request that indicates an error occurred.", @@ -6278,195 +4772,6 @@ "title": "JSONRPCResponse", "type": "object" }, - "ListConversationsParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "modelProviders": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "pageSize": { - "format": "uint", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "title": "ListConversationsParams", - "type": "object" - }, - "ListConversationsResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "items": { - "items": { - "$ref": "#/definitions/ConversationSummary" - }, - "type": "array" - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "items" - ], - "title": "ListConversationsResponse", - "type": "object" - }, - "LocalShellAction": { - "oneOf": [ - { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "exec" - ], - "title": "ExecLocalShellActionType", - "type": "string" - }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "command", - "type" - ], - "title": "ExecLocalShellAction", - "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "LoginApiKeyParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "apiKey": { - "type": "string" - } - }, - "required": [ - "apiKey" - ], - "title": "LoginApiKeyParams", - "type": "object" - }, - "LoginApiKeyResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "LoginApiKeyResponse", - "type": "object" - }, - "LoginChatGptCompleteNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Deprecated in favor of AccountLoginCompletedNotification.", - "properties": { - "error": { - "type": [ - "string", - "null" - ] - }, - "loginId": { - "type": "string" - }, - "success": { - "type": "boolean" - } - }, - "required": [ - "loginId", - "success" - ], - "title": "LoginChatGptCompleteNotification", - "type": "object" - }, - "LoginChatGptResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "authUrl": { - "type": "string" - }, - "loginId": { - "type": "string" - } - }, - "required": [ - "authUrl", - "loginId" - ], - "title": "LoginChatGptResponse", - "type": "object" - }, - "LogoutChatGptResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "LogoutChatGptResponse", - "type": "object" - }, - "McpAuthStatus": { - "enum": [ - "unsupported", - "not_logged_in", - "bearer_token", - "o_auth" - ], - "type": "string" - }, "McpInvocation": { "properties": { "arguments": { @@ -6516,7 +4821,7 @@ "required": [ "state" ], - "title": "StateMcpStartupStatus", + "title": "StartingMcpStartupStatus", "type": "object" }, { @@ -6531,7 +4836,7 @@ "required": [ "state" ], - "title": "StateMcpStartupStatus2", + "title": "ReadyMcpStartupStatus", "type": "object" }, { @@ -6564,52 +4869,11 @@ "required": [ "state" ], - "title": "StateMcpStartupStatus3", + "title": "CancelledMcpStartupStatus", "type": "object" } ] }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], - "type": "string" - }, - { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" - ], - "type": "string" - }, - "ModelRerouteReason": { - "enum": [ - "high_risk_cyber_activity" - ], - "type": "string" - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, "NetworkApprovalContext": { "properties": { "host": { @@ -6629,122 +4893,11 @@ "enum": [ "http", "https", - "socks5_tcp", - "socks5_udp" + "socks5Tcp", + "socks5Udp" ], "type": "string" }, - "NewConversationParams": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "baseInstructions": { - "type": [ - "string", - "null" - ] - }, - "compactPrompt": { - "type": [ - "string", - "null" - ] - }, - "config": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "developerInstructions": { - "type": [ - "string", - "null" - ] - }, - "includeApplyPatchTool": { - "type": [ - "boolean", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelProvider": { - "type": [ - "string", - "null" - ] - }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "sandbox": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "NewConversationResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "model": { - "type": "string" - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "model", - "rolloutPath" - ], - "title": "NewConversationResponse", - "type": "object" - }, "ParsedCommand": { "oneOf": [ { @@ -6856,14 +5009,6 @@ } ] }, - "PatchApplyStatus": { - "enum": [ - "completed", - "failed", - "declined" - ], - "type": "string" - }, "PlanItemArg": { "additionalProperties": false, "properties": { @@ -6880,220 +5025,6 @@ ], "type": "object" }, - "PlanType": { - "enum": [ - "free", - "go", - "plus", - "pro", - "team", - "business", - "enterprise", - "edu", - "unknown" - ], - "type": "string" - }, - "Profile": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "chatgptBaseUrl": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelProvider": { - "type": [ - "string", - "null" - ] - }, - "modelReasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "modelReasoningSummary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "modelVerbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RateLimitSnapshot": { - "properties": { - "credits": { - "anyOf": [ - { - "$ref": "#/definitions/CreditsSnapshot" - }, - { - "type": "null" - } - ] - }, - "limit_id": { - "type": [ - "string", - "null" - ] - }, - "limit_name": { - "type": [ - "string", - "null" - ] - }, - "plan_type": { - "anyOf": [ - { - "$ref": "#/definitions/PlanType" - }, - { - "type": "null" - } - ] - }, - "primary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - }, - "secondary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RateLimitWindow": { - "properties": { - "resets_at": { - "description": "Unix timestamp (seconds since epoch) when the window resets.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "used_percent": { - "description": "Percentage (0-100) of the window that has been consumed.", - "format": "double", - "type": "number" - }, - "window_minutes": { - "description": "Rolling window duration, in minutes.", - "format": "int64", - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "used_percent" - ], - "type": "object" - }, - "ReadOnlyAccess": { - "description": "Determines how read-only file access is granted inside a restricted sandbox.", - "oneOf": [ - { - "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", - "properties": { - "include_platform_defaults": { - "default": true, - "description": "Include built-in platform read roots required for basic process execution.", - "type": "boolean" - }, - "readable_roots": { - "description": "Additional absolute roots that should be readable.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "description": "Allow unrestricted file reads.", - "properties": { - "type": { - "enum": [ - "full-access" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "RealtimeAudioFrame": { "properties": { "data": { @@ -7208,106 +5139,6 @@ } ] }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, - "ReasoningSummary": { - "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", - "oneOf": [ - { - "enum": [ - "auto", - "concise", - "detailed" - ], - "type": "string" - }, - { - "description": "Option to disable reasoning summaries.", - "enum": [ - "none" - ], - "type": "string" - } - ] - }, "RejectConfig": { "properties": { "mcp_elicitations": { @@ -7330,43 +5161,6 @@ ], "type": "object" }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, - "RemoveConversationListenerParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "subscriptionId": { - "type": "string" - } - }, - "required": [ - "subscriptionId" - ], - "title": "RemoveConversationListenerParams", - "type": "object" - }, - "RemoveConversationSubscriptionResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "RemoveConversationSubscriptionResponse", - "type": "object" - }, "RequestId": { "anyOf": [ { @@ -7430,447 +5224,6 @@ ], "type": "object" }, - "Resource": { - "description": "A known resource that the server is capable of reading.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "size": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "name", - "uri" - ], - "type": "object" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server.", - "properties": { - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "type": "string" - } - }, - "required": [ - "name", - "uriTemplate" - ], - "type": "object" - }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" - }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "type": "string" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction" - ], - "title": "CompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionResponseItem", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" - } - ] - }, "Result_of_CallToolResult_or_String": { "oneOf": [ { @@ -7899,78 +5252,6 @@ } ] }, - "ResumeConversationParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history": { - "items": { - "$ref": "#/definitions/ResponseItem" - }, - "type": [ - "array", - "null" - ] - }, - "overrides": { - "anyOf": [ - { - "$ref": "#/definitions/NewConversationParams" - }, - { - "type": "null" - } - ] - }, - "path": { - "type": [ - "string", - "null" - ] - } - }, - "title": "ResumeConversationParams", - "type": "object" - }, - "ResumeConversationResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "model", - "rolloutPath" - ], - "title": "ResumeConversationResponse", - "type": "object" - }, "ReviewCodeLocation": { "description": "Location of the code related to a review finding.", "properties": { @@ -8123,338 +5404,6 @@ ], "type": "object" }, - "ReviewTarget": { - "oneOf": [ - { - "description": "Review the working tree: staged, unstaged, and untracked files.", - "properties": { - "type": { - "enum": [ - "uncommittedChanges" - ], - "title": "UncommittedChangesReviewTargetType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UncommittedChangesReviewTarget", - "type": "object" - }, - { - "description": "Review changes between the current branch and the given base branch.", - "properties": { - "branch": { - "type": "string" - }, - "type": { - "enum": [ - "baseBranch" - ], - "title": "BaseBranchReviewTargetType", - "type": "string" - } - }, - "required": [ - "branch", - "type" - ], - "title": "BaseBranchReviewTarget", - "type": "object" - }, - { - "description": "Review the changes introduced by a specific commit.", - "properties": { - "sha": { - "type": "string" - }, - "title": { - "description": "Optional human-readable label (e.g., commit subject) for UIs.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "commit" - ], - "title": "CommitReviewTargetType", - "type": "string" - } - }, - "required": [ - "sha", - "type" - ], - "title": "CommitReviewTarget", - "type": "object" - }, - { - "description": "Arbitrary instructions provided by the user.", - "properties": { - "instructions": { - "type": "string" - }, - "type": { - "enum": [ - "custom" - ], - "title": "CustomReviewTargetType", - "type": "string" - } - }, - "required": [ - "instructions", - "type" - ], - "title": "CustomReviewTarget", - "type": "object" - } - ] - }, - "SandboxMode": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access configuration.", - "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "read_only_access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - }, - "SandboxSettings": { - "properties": { - "excludeSlashTmp": { - "type": [ - "boolean", - "null" - ] - }, - "excludeTmpdirEnvVar": { - "type": [ - "boolean", - "null" - ] - }, - "networkAccess": { - "type": [ - "boolean", - "null" - ] - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "type": "object" - }, - "SendUserMessageParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "items": { - "items": { - "$ref": "#/definitions/InputItem" - }, - "type": "array" - } - }, - "required": [ - "conversationId", - "items" - ], - "title": "SendUserMessageParams", - "type": "object" - }, - "SendUserMessageResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SendUserMessageResponse", - "type": "object" - }, - "SendUserTurnParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "approvalPolicy": { - "$ref": "#/definitions/AskForApproval" - }, - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "cwd": { - "type": "string" - }, - "effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "items": { - "items": { - "$ref": "#/definitions/InputItem" - }, - "type": "array" - }, - "model": { - "type": "string" - }, - "outputSchema": { - "description": "Optional JSON Schema used to constrain the final assistant message for this turn." - }, - "sandboxPolicy": { - "$ref": "#/definitions/SandboxPolicy" - }, - "summary": { - "$ref": "#/definitions/ReasoningSummary" - } - }, - "required": [ - "approvalPolicy", - "conversationId", - "cwd", - "items", - "model", - "sandboxPolicy", - "summary" - ], - "title": "SendUserTurnParams", - "type": "object" - }, - "SendUserTurnResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SendUserTurnResponse", - "type": "object" - }, "ServerNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Notification sent from the server to the client.", @@ -8720,27 +5669,6 @@ "title": "Item/completedNotification", "type": "object" }, - { - "description": "This event is internal-only. Used by Codex Cloud.", - "properties": { - "method": { - "enum": [ - "rawResponseItem/completed" - ], - "title": "RawResponseItem/completedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/RawResponseItemCompletedNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "RawResponseItem/completedNotification", - "type": "object" - }, { "properties": { "method": { @@ -9183,68 +6111,6 @@ ], "title": "Account/login/completedNotification", "type": "object" - }, - { - "description": "DEPRECATED NOTIFICATIONS below", - "properties": { - "method": { - "enum": [ - "authStatusChange" - ], - "title": "AuthStatusChangeNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/AuthStatusChangeNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "AuthStatusChangeNotification", - "type": "object" - }, - { - "description": "Deprecated: use `account/login/completed` instead.", - "properties": { - "method": { - "enum": [ - "loginChatGptComplete" - ], - "title": "LoginChatGptCompleteNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/LoginChatGptCompleteNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "LoginChatGptCompleteNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "sessionConfigured" - ], - "title": "SessionConfiguredNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SessionConfiguredNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "SessionConfiguredNotification", - "type": "object" } ], "title": "ServerNotification" @@ -9430,58 +6296,6 @@ ], "title": "ServerRequest" }, - "SessionConfiguredNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "historyEntryCount": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "historyLogId": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "rolloutPath": { - "type": "string" - }, - "sessionId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "historyEntryCount", - "historyLogId", - "model", - "rolloutPath", - "sessionId" - ], - "title": "SessionConfiguredNotification", - "type": "object" - }, "SessionNetworkProxyRuntime": { "properties": { "admin_addr": { @@ -9501,258 +6315,6 @@ ], "type": "object" }, - "SessionSource": { - "oneOf": [ - { - "enum": [ - "cli", - "vscode", - "exec", - "mcp", - "unknown" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "subagent": { - "$ref": "#/definitions/SubAgentSource" - } - }, - "required": [ - "subagent" - ], - "title": "SubagentSessionSource", - "type": "object" - } - ] - }, - "SetDefaultModelParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "model": { - "type": [ - "string", - "null" - ] - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - } - }, - "title": "SetDefaultModelParams", - "type": "object" - }, - "SetDefaultModelResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SetDefaultModelResponse", - "type": "object" - }, - "SkillDependencies": { - "properties": { - "tools": { - "items": { - "$ref": "#/definitions/SkillToolDependency" - }, - "type": "array" - } - }, - "required": [ - "tools" - ], - "type": "object" - }, - "SkillErrorInfo": { - "properties": { - "message": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "message", - "path" - ], - "type": "object" - }, - "SkillInterface": { - "properties": { - "brand_color": { - "type": [ - "string", - "null" - ] - }, - "default_prompt": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "icon_large": { - "type": [ - "string", - "null" - ] - }, - "icon_small": { - "type": [ - "string", - "null" - ] - }, - "short_description": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "SkillMetadata": { - "properties": { - "dependencies": { - "anyOf": [ - { - "$ref": "#/definitions/SkillDependencies" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "interface": { - "anyOf": [ - { - "$ref": "#/definitions/SkillInterface" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "scope": { - "$ref": "#/definitions/SkillScope" - }, - "short_description": { - "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "description", - "enabled", - "name", - "path", - "scope" - ], - "type": "object" - }, - "SkillScope": { - "enum": [ - "user", - "repo", - "system", - "admin" - ], - "type": "string" - }, - "SkillToolDependency": { - "properties": { - "command": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "transport": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "value" - ], - "type": "object" - }, - "SkillsListEntry": { - "properties": { - "cwd": { - "type": "string" - }, - "errors": { - "items": { - "$ref": "#/definitions/SkillErrorInfo" - }, - "type": "array" - }, - "skills": { - "items": { - "$ref": "#/definitions/SkillMetadata" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "errors", - "skills" - ], - "type": "object" - }, "StepStatus": { "enum": [ "pending", @@ -9761,97 +6323,6 @@ ], "type": "string" }, - "SubAgentSource": { - "oneOf": [ - { - "enum": [ - "review", - "compact", - "memory_consolidation" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "thread_spawn": { - "properties": { - "agent_nickname": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "agent_role": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "depth": { - "format": "int32", - "type": "integer" - }, - "parent_thread_id": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "depth", - "parent_thread_id" - ], - "type": "object" - } - }, - "required": [ - "thread_spawn" - ], - "title": "ThreadSpawnSubAgentSource", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "other": { - "type": "string" - } - }, - "required": [ - "other" - ], - "title": "OtherSubAgentSource", - "type": "object" - } - ] - }, - "TextElement": { - "properties": { - "byte_range": { - "allOf": [ - { - "$ref": "#/definitions/ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byte_range" - ], - "type": "object" - }, - "ThreadId": { - "type": "string" - }, "TokenUsage": { "properties": { "cached_input_tokens": { @@ -9906,42 +6377,6 @@ ], "type": "object" }, - "Tool": { - "description": "Definition for a tool the client can call.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "inputSchema": true, - "name": { - "type": "string" - }, - "outputSchema": true, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "inputSchema", - "name" - ], - "type": "object" - }, "ToolRequestUserInputAnswer": { "description": "EXPERIMENTAL. Captures a user's answer to a request_user_input question.", "properties": { @@ -10056,23 +6491,6 @@ "title": "ToolRequestUserInputResponse", "type": "object" }, - "Tools": { - "properties": { - "viewImage": { - "type": [ - "boolean", - "null" - ] - }, - "webSearch": { - "type": [ - "boolean", - "null" - ] - } - }, - "type": "object" - }, "TurnAbortReason": { "enum": [ "interrupted", @@ -10257,410 +6675,6 @@ } ] }, - "UserInfoResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "allegedUserEmail": { - "type": [ - "string", - "null" - ] - } - }, - "title": "UserInfoResponse", - "type": "object" - }, - "UserInput": { - "description": "User input", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` that should be treated as special elements. These are byte ranges into the UTF-8 `text` buffer and are used to render or persist rich input markers (e.g., image placeholders) across history and resume without mutating the literal text.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextUserInputType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextUserInput", - "type": "object" - }, - { - "description": "Pre‑encoded data: URI image.", - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageUserInputType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "ImageUserInput", - "type": "object" - }, - { - "description": "Local image path provided by the user. This will be converted to an `Image` variant (base64 data URL) during request serialization.", - "properties": { - "path": { - "type": "string" - }, - "type": { - "enum": [ - "local_image" - ], - "title": "LocalImageUserInputType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "LocalImageUserInput", - "type": "object" - }, - { - "description": "Skill selected by the user (name + path to SKILL.md).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "skill" - ], - "title": "SkillUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "SkillUserInput", - "type": "object" - }, - { - "description": "Explicit mention selected by the user (name + app://connector id).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "mention" - ], - "title": "MentionUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "MentionUserInput", - "type": "object" - } - ] - }, - "UserSavedConfig": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "forcedChatgptWorkspaceId": { - "type": [ - "string", - "null" - ] - }, - "forcedLoginMethod": { - "anyOf": [ - { - "$ref": "#/definitions/ForcedLoginMethod" - }, - { - "type": "null" - } - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelReasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "modelReasoningSummary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "modelVerbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/Profile" - }, - "type": "object" - }, - "sandboxMode": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode" - }, - { - "type": "null" - } - ] - }, - "sandboxSettings": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxSettings" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/Tools" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "profiles" - ], - "type": "object" - }, - "V1ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "V1TextElement": { - "properties": { - "byteRange": { - "allOf": [ - { - "$ref": "#/definitions/V1ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byteRange" - ], - "type": "object" - }, - "Verbosity": { - "description": "Controls output length/detail on GPT-5 models via the Responses API. Serialized with lowercase values to match the OpenAI API.", - "enum": [ - "low", - "medium", - "high" - ], - "type": "string" - }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] - }, "v2": { "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.", diff --git a/codex-rs/app-server-protocol/schema/json/v1/AddConversationListenerParams.json b/codex-rs/app-server-protocol/schema/json/v1/AddConversationListenerParams.json deleted file mode 100644 index 67b8bd7d81..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/AddConversationListenerParams.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ThreadId": { - "type": "string" - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "experimentalRawEvents": { - "default": false, - "type": "boolean" - } - }, - "required": [ - "conversationId" - ], - "title": "AddConversationListenerParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/AddConversationSubscriptionResponse.json b/codex-rs/app-server-protocol/schema/json/v1/AddConversationSubscriptionResponse.json deleted file mode 100644 index 8bd1bcf016..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/AddConversationSubscriptionResponse.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "subscriptionId": { - "type": "string" - } - }, - "required": [ - "subscriptionId" - ], - "title": "AddConversationSubscriptionResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/ArchiveConversationParams.json b/codex-rs/app-server-protocol/schema/json/v1/ArchiveConversationParams.json deleted file mode 100644 index 7ee5b16b3b..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/ArchiveConversationParams.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ThreadId": { - "type": "string" - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "rolloutPath" - ], - "title": "ArchiveConversationParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/ArchiveConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ArchiveConversationResponse.json deleted file mode 100644 index 253d15cc0d..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/ArchiveConversationResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ArchiveConversationResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/AuthStatusChangeNotification.json b/codex-rs/app-server-protocol/schema/json/v1/AuthStatusChangeNotification.json deleted file mode 100644 index 2aee28ba9c..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/AuthStatusChangeNotification.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AuthMode": { - "description": "Authentication mode for OpenAI-backed providers.", - "oneOf": [ - { - "description": "OpenAI API key provided by the caller and stored by Codex.", - "enum": [ - "apikey" - ], - "type": "string" - }, - { - "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", - "enum": [ - "chatgpt" - ], - "type": "string" - }, - { - "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", - "enum": [ - "chatgptAuthTokens" - ], - "type": "string" - } - ] - } - }, - "description": "Deprecated notification. Use AccountUpdatedNotification instead.", - "properties": { - "authMethod": { - "anyOf": [ - { - "$ref": "#/definitions/AuthMode" - }, - { - "type": "null" - } - ] - } - }, - "title": "AuthStatusChangeNotification", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptParams.json b/codex-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptParams.json deleted file mode 100644 index 8367dac087..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptParams.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "loginId": { - "type": "string" - } - }, - "required": [ - "loginId" - ], - "title": "CancelLoginChatGptParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptResponse.json b/codex-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptResponse.json deleted file mode 100644 index a4e1c333c4..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "CancelLoginChatGptResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandParams.json b/codex-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandParams.json deleted file mode 100644 index ec5b789352..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandParams.json +++ /dev/null @@ -1,225 +0,0 @@ -{ - "$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" - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "ReadOnlyAccess": { - "description": "Determines how read-only file access is granted inside a restricted sandbox.", - "oneOf": [ - { - "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", - "properties": { - "include_platform_defaults": { - "default": true, - "description": "Include built-in platform read roots required for basic process execution.", - "type": "boolean" - }, - "readable_roots": { - "description": "Additional absolute roots that should be readable.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "description": "Allow unrestricted file reads.", - "properties": { - "type": { - "enum": [ - "full-access" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access configuration.", - "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "read_only_access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - } - }, - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "sandboxPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - }, - { - "type": "null" - } - ] - }, - "timeoutMs": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "command" - ], - "title": "ExecOneOffCommandParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandResponse.json deleted file mode 100644 index 121ed648ff..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandResponse.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "exitCode": { - "format": "int32", - "type": "integer" - }, - "stderr": { - "type": "string" - }, - "stdout": { - "type": "string" - } - }, - "required": [ - "exitCode", - "stderr", - "stdout" - ], - "title": "ExecOneOffCommandResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationParams.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationParams.json deleted file mode 100644 index b712352776..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationParams.json +++ /dev/null @@ -1,195 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "NewConversationParams": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "baseInstructions": { - "type": [ - "string", - "null" - ] - }, - "compactPrompt": { - "type": [ - "string", - "null" - ] - }, - "config": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "developerInstructions": { - "type": [ - "string", - "null" - ] - }, - "includeApplyPatchTool": { - "type": [ - "boolean", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelProvider": { - "type": [ - "string", - "null" - ] - }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "sandbox": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "SandboxMode": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - }, - "ThreadId": { - "type": "string" - } - }, - "properties": { - "conversationId": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "overrides": { - "anyOf": [ - { - "$ref": "#/definitions/NewConversationParams" - }, - { - "type": "null" - } - ] - }, - "path": { - "type": [ - "string", - "null" - ] - } - }, - "title": "ForkConversationParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json deleted file mode 100644 index 70521ec729..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ /dev/null @@ -1,5776 +0,0 @@ -{ - "$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" - }, - "AgentMessageContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Text" - ], - "title": "TextAgentMessageContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextAgentMessageContent", - "type": "object" - } - ] - }, - "AgentStatus": { - "description": "Agent lifecycle status, derived from emitted events.", - "oneOf": [ - { - "description": "Agent is waiting for initialization.", - "enum": [ - "pending_init" - ], - "type": "string" - }, - { - "description": "Agent is currently running.", - "enum": [ - "running" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Agent is done. Contains the final assistant message.", - "properties": { - "completed": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "completed" - ], - "title": "CompletedAgentStatus", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Agent encountered an error.", - "properties": { - "errored": { - "type": "string" - } - }, - "required": [ - "errored" - ], - "title": "ErroredAgentStatus", - "type": "object" - }, - { - "description": "Agent has been shutdown.", - "enum": [ - "shutdown" - ], - "type": "string" - }, - { - "description": "Agent is not found.", - "enum": [ - "not_found" - ], - "type": "string" - } - ] - }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "CallToolResult": { - "description": "The server's response to a tool call.", - "properties": { - "_meta": true, - "content": { - "items": true, - "type": "array" - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true - }, - "required": [ - "content" - ], - "type": "object" - }, - "CodexErrorInfo": { - "description": "Codex errors that we expose to clients.", - "oneOf": [ - { - "enum": [ - "context_window_exceeded", - "usage_limit_exceeded", - "server_overloaded", - "internal_server_error", - "unauthorized", - "bad_request", - "sandbox_error", - "thread_rollback_failed", - "other" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "http_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "http_connection_failed" - ], - "title": "HttpConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Failed to connect to the response SSE stream.", - "properties": { - "response_stream_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_connection_failed" - ], - "title": "ResponseStreamConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "The response SSE stream disconnected in the middle of a turnbefore completion.", - "properties": { - "response_stream_disconnected": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_disconnected" - ], - "title": "ResponseStreamDisconnectedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Reached the retry limit for responses.", - "properties": { - "response_too_many_failed_attempts": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_too_many_failed_attempts" - ], - "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", - "type": "object" - } - ] - }, - "CollabAgentRef": { - "properties": { - "agent_nickname": { - "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "agent_role": { - "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver/new agent." - } - }, - "required": [ - "thread_id" - ], - "type": "object" - }, - "CollabAgentStatusEntry": { - "properties": { - "agent_nickname": { - "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "agent_role": { - "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the agent." - }, - "thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver/new agent." - } - }, - "required": [ - "status", - "thread_id" - ], - "type": "object" - }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, - "CreditsSnapshot": { - "properties": { - "balance": { - "type": [ - "string", - "null" - ] - }, - "has_credits": { - "type": "boolean" - }, - "unlimited": { - "type": "boolean" - } - }, - "required": [ - "has_credits", - "unlimited" - ], - "type": "object" - }, - "CustomPrompt": { - "properties": { - "argument_hint": { - "type": [ - "string", - "null" - ] - }, - "content": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "content", - "name", - "path" - ], - "type": "object" - }, - "Duration": { - "properties": { - "nanos": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "secs": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "nanos", - "secs" - ], - "type": "object" - }, - "EventMsg": { - "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", - "oneOf": [ - { - "description": "Error while executing a submission", - "properties": { - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "error" - ], - "title": "ErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "ErrorEventMsg", - "type": "object" - }, - { - "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "warning" - ], - "title": "WarningEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "WarningEventMsg", - "type": "object" - }, - { - "description": "Realtime conversation lifecycle start event.", - "properties": { - "session_id": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "realtime_conversation_started" - ], - "title": "RealtimeConversationStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RealtimeConversationStartedEventMsg", - "type": "object" - }, - { - "description": "Realtime conversation streaming payload event.", - "properties": { - "payload": { - "$ref": "#/definitions/RealtimeEvent" - }, - "type": { - "enum": [ - "realtime_conversation_realtime" - ], - "title": "RealtimeConversationRealtimeEventMsgType", - "type": "string" - } - }, - "required": [ - "payload", - "type" - ], - "title": "RealtimeConversationRealtimeEventMsg", - "type": "object" - }, - { - "description": "Realtime conversation lifecycle close event.", - "properties": { - "reason": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "realtime_conversation_closed" - ], - "title": "RealtimeConversationClosedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RealtimeConversationClosedEventMsg", - "type": "object" - }, - { - "description": "Model routing changed from the requested model to a different model.", - "properties": { - "from_model": { - "type": "string" - }, - "reason": { - "$ref": "#/definitions/ModelRerouteReason" - }, - "to_model": { - "type": "string" - }, - "type": { - "enum": [ - "model_reroute" - ], - "title": "ModelRerouteEventMsgType", - "type": "string" - } - }, - "required": [ - "from_model", - "reason", - "to_model", - "type" - ], - "title": "ModelRerouteEventMsg", - "type": "object" - }, - { - "description": "Conversation history was compacted (either automatically or manually).", - "properties": { - "type": { - "enum": [ - "context_compacted" - ], - "title": "ContextCompactedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactedEventMsg", - "type": "object" - }, - { - "description": "Conversation history was rolled back by dropping the last N user turns.", - "properties": { - "num_turns": { - "description": "Number of user turns that were removed from context.", - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "thread_rolled_back" - ], - "title": "ThreadRolledBackEventMsgType", - "type": "string" - } - }, - "required": [ - "num_turns", - "type" - ], - "title": "ThreadRolledBackEventMsg", - "type": "object" - }, - { - "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", - "properties": { - "collaboration_mode_kind": { - "allOf": [ - { - "$ref": "#/definitions/ModeKind" - } - ], - "default": "default" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "task_started" - ], - "title": "TaskStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "turn_id", - "type" - ], - "title": "TaskStartedEventMsg", - "type": "object" - }, - { - "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", - "properties": { - "last_agent_message": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "task_complete" - ], - "title": "TaskCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "turn_id", - "type" - ], - "title": "TaskCompleteEventMsg", - "type": "object" - }, - { - "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", - "properties": { - "info": { - "anyOf": [ - { - "$ref": "#/definitions/TokenUsageInfo" - }, - { - "type": "null" - } - ] - }, - "rate_limits": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitSnapshot" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "token_count" - ], - "title": "TokenCountEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TokenCountEventMsg", - "type": "object" - }, - { - "description": "Agent text output message", - "properties": { - "message": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "default": null - }, - "type": { - "enum": [ - "agent_message" - ], - "title": "AgentMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "AgentMessageEventMsg", - "type": "object" - }, - { - "description": "User/system input message (what was sent to the model)", - "properties": { - "images": { - "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "local_images": { - "default": [], - "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", - "items": { - "type": "string" - }, - "type": "array" - }, - "message": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `message` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "user_message" - ], - "title": "UserMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "UserMessageEventMsg", - "type": "object" - }, - { - "description": "Agent text output delta message", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_delta" - ], - "title": "AgentMessageDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentMessageDeltaEventMsg", - "type": "object" - }, - { - "description": "Reasoning event from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning" - ], - "title": "AgentReasoningEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_delta" - ], - "title": "AgentReasoningDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningDeltaEventMsg", - "type": "object" - }, - { - "description": "Raw chain-of-thought from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content" - ], - "title": "AgentReasoningRawContentEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningRawContentEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning content delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content_delta" - ], - "title": "AgentReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", - "properties": { - "item_id": { - "default": "", - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "type": { - "enum": [ - "agent_reasoning_section_break" - ], - "title": "AgentReasoningSectionBreakEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AgentReasoningSectionBreakEventMsg", - "type": "object" - }, - { - "description": "Ack the client's configure message.", - "properties": { - "approval_policy": { - "allOf": [ - { - "$ref": "#/definitions/AskForApproval" - } - ], - "description": "When to escalate for approval for execution" - }, - "cwd": { - "description": "Working directory that should be treated as the *root* of the session.", - "type": "string" - }, - "forked_from_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history_entry_count": { - "description": "Current number of entries in the history log.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "history_log_id": { - "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initial_messages": { - "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "description": "Tell the client what model is being queried.", - "type": "string" - }, - "model_provider_id": { - "type": "string" - }, - "network_proxy": { - "anyOf": [ - { - "$ref": "#/definitions/SessionNetworkProxyRuntime" - }, - { - "type": "null" - } - ], - "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." - }, - "reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ], - "description": "The effort the model is putting into reasoning about the user's request." - }, - "rollout_path": { - "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", - "type": [ - "string", - "null" - ] - }, - "sandbox_policy": { - "allOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - } - ], - "description": "How to sandbox commands executed in the system" - }, - "session_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "description": "Optional user-facing thread name (may be unset).", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "session_configured" - ], - "title": "SessionConfiguredEventMsgType", - "type": "string" - } - }, - "required": [ - "approval_policy", - "cwd", - "history_entry_count", - "history_log_id", - "model", - "model_provider_id", - "sandbox_policy", - "session_id", - "type" - ], - "title": "SessionConfiguredEventMsg", - "type": "object" - }, - { - "description": "Updated session metadata (e.g., thread name changes).", - "properties": { - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "thread_name_updated" - ], - "title": "ThreadNameUpdatedEventMsgType", - "type": "string" - } - }, - "required": [ - "thread_id", - "type" - ], - "title": "ThreadNameUpdatedEventMsg", - "type": "object" - }, - { - "description": "Incremental MCP startup progress updates.", - "properties": { - "server": { - "description": "Server name being started.", - "type": "string" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/McpStartupStatus" - } - ], - "description": "Current startup status." - }, - "type": { - "enum": [ - "mcp_startup_update" - ], - "title": "McpStartupUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "server", - "status", - "type" - ], - "title": "McpStartupUpdateEventMsg", - "type": "object" - }, - { - "description": "Aggregate MCP startup completion summary.", - "properties": { - "cancelled": { - "items": { - "type": "string" - }, - "type": "array" - }, - "failed": { - "items": { - "$ref": "#/definitions/McpStartupFailure" - }, - "type": "array" - }, - "ready": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "mcp_startup_complete" - ], - "title": "McpStartupCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "cancelled", - "failed", - "ready", - "type" - ], - "title": "McpStartupCompleteEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the McpToolCallEnd event.", - "type": "string" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "type": { - "enum": [ - "mcp_tool_call_begin" - ], - "title": "McpToolCallBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "invocation", - "type" - ], - "title": "McpToolCallBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier for the corresponding McpToolCallBegin that finished.", - "type": "string" - }, - "duration": { - "$ref": "#/definitions/Duration" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "result": { - "allOf": [ - { - "$ref": "#/definitions/Result_of_CallToolResult_or_String" - } - ], - "description": "Result of the tool call. Note this could be an error." - }, - "type": { - "enum": [ - "mcp_tool_call_end" - ], - "title": "McpToolCallEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "duration", - "invocation", - "result", - "type" - ], - "title": "McpToolCallEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_begin" - ], - "title": "WebSearchBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "WebSearchBeginEventMsg", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "call_id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_end" - ], - "title": "WebSearchEndEventMsgType", - "type": "string" - } - }, - "required": [ - "action", - "call_id", - "query", - "type" - ], - "title": "WebSearchEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the server is about to execute a command.", - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the ExecCommandEnd event.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_begin" - ], - "title": "ExecCommandBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "turn_id", - "type" - ], - "title": "ExecCommandBeginEventMsg", - "type": "object" - }, - { - "description": "Incremental chunk of output from a running command.", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "chunk": { - "description": "Raw bytes from the stream (may not be valid UTF-8).", - "type": "string" - }, - "stream": { - "allOf": [ - { - "$ref": "#/definitions/ExecOutputStream" - } - ], - "description": "Which stream produced this chunk." - }, - "type": { - "enum": [ - "exec_command_output_delta" - ], - "title": "ExecCommandOutputDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "chunk", - "stream", - "type" - ], - "title": "ExecCommandOutputDeltaEventMsg", - "type": "object" - }, - { - "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "process_id": { - "description": "Process id associated with the running command.", - "type": "string" - }, - "stdin": { - "description": "Stdin sent to the running session.", - "type": "string" - }, - "type": { - "enum": [ - "terminal_interaction" - ], - "title": "TerminalInteractionEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "process_id", - "stdin", - "type" - ], - "title": "TerminalInteractionEventMsg", - "type": "object" - }, - { - "properties": { - "aggregated_output": { - "default": "", - "description": "Captured aggregated output", - "type": "string" - }, - "call_id": { - "description": "Identifier for the ExecCommandBegin that finished.", - "type": "string" - }, - "command": { - "description": "The command that was executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "duration": { - "allOf": [ - { - "$ref": "#/definitions/Duration" - } - ], - "description": "The duration of the command execution." - }, - "exit_code": { - "description": "The command's exit code.", - "format": "int32", - "type": "integer" - }, - "formatted_output": { - "description": "Formatted output from the command, as seen by the model.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandStatus" - } - ], - "description": "Completion status for this command execution." - }, - "stderr": { - "description": "Captured stderr", - "type": "string" - }, - "stdout": { - "description": "Captured stdout", - "type": "string" - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_end" - ], - "title": "ExecCommandEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "duration", - "exit_code", - "formatted_output", - "parsed_cmd", - "status", - "stderr", - "stdout", - "turn_id", - "type" - ], - "title": "ExecCommandEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent attached a local image via the view_image tool.", - "properties": { - "call_id": { - "description": "Identifier for the originating tool call.", - "type": "string" - }, - "path": { - "description": "Local filesystem path provided to the tool.", - "type": "string" - }, - "type": { - "enum": [ - "view_image_tool_call" - ], - "title": "ViewImageToolCallEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "path", - "type" - ], - "title": "ViewImageToolCallEventMsg", - "type": "object" - }, - { - "properties": { - "approval_id": { - "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", - "type": [ - "string", - "null" - ] - }, - "call_id": { - "description": "Identifier for the associated command execution item.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory.", - "type": "string" - }, - "network_approval_context": { - "anyOf": [ - { - "$ref": "#/definitions/NetworkApprovalContext" - }, - { - "type": "null" - } - ], - "description": "Optional network context for a blocked request that can be approved." - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "proposed_execpolicy_amendment": { - "description": "Proposed execpolicy amendment that can be applied to allow future runs.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "reason": { - "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "exec_approval_request" - ], - "title": "ExecApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "type" - ], - "title": "ExecApprovalRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated tool call, if available.", - "type": "string" - }, - "questions": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestion" - }, - "type": "array" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "request_user_input" - ], - "title": "RequestUserInputEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "questions", - "type" - ], - "title": "RequestUserInputEventMsg", - "type": "object" - }, - { - "properties": { - "arguments": true, - "callId": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "turnId": { - "type": "string" - }, - "type": { - "enum": [ - "dynamic_tool_call_request" - ], - "title": "DynamicToolCallRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "arguments", - "callId", - "tool", - "turnId", - "type" - ], - "title": "DynamicToolCallRequestEventMsg", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "message": { - "type": "string" - }, - "server_name": { - "type": "string" - }, - "type": { - "enum": [ - "elicitation_request" - ], - "title": "ElicitationRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "message", - "server_name", - "type" - ], - "title": "ElicitationRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated patch apply call, if available.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "type": "object" - }, - "grant_root": { - "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", - "type": [ - "string", - "null" - ] - }, - "reason": { - "description": "Optional explanatory reason (e.g. request for extra write access).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", - "type": "string" - }, - "type": { - "enum": [ - "apply_patch_approval_request" - ], - "title": "ApplyPatchApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "changes", - "type" - ], - "title": "ApplyPatchApprovalRequestEventMsg", - "type": "object" - }, - { - "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", - "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", - "type": [ - "string", - "null" - ] - }, - "summary": { - "description": "Concise summary of what is deprecated.", - "type": "string" - }, - "type": { - "enum": [ - "deprecation_notice" - ], - "title": "DeprecationNoticeEventMsgType", - "type": "string" - } - }, - "required": [ - "summary", - "type" - ], - "title": "DeprecationNoticeEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "background_event" - ], - "title": "BackgroundEventEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "BackgroundEventEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "undo_started" - ], - "title": "UndoStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UndoStartedEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "success": { - "type": "boolean" - }, - "type": { - "enum": [ - "undo_completed" - ], - "title": "UndoCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "success", - "type" - ], - "title": "UndoCompletedEventMsg", - "type": "object" - }, - { - "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", - "properties": { - "additional_details": { - "default": null, - "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", - "type": [ - "string", - "null" - ] - }, - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "stream_error" - ], - "title": "StreamErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "StreamErrorEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", - "properties": { - "auto_approved": { - "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", - "type": "boolean" - }, - "call_id": { - "description": "Identifier so this can be paired with the PatchApplyEnd event.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "description": "The changes to be applied.", - "type": "object" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_begin" - ], - "title": "PatchApplyBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "auto_approved", - "call_id", - "changes", - "type" - ], - "title": "PatchApplyBeginEventMsg", - "type": "object" - }, - { - "description": "Notification that a patch application has finished.", - "properties": { - "call_id": { - "description": "Identifier for the PatchApplyBegin that finished.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "default": {}, - "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", - "type": "object" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/PatchApplyStatus" - } - ], - "description": "Completion status for this patch application." - }, - "stderr": { - "description": "Captured stderr (parser errors, IO failures, etc.).", - "type": "string" - }, - "stdout": { - "description": "Captured stdout (summary printed by apply_patch).", - "type": "string" - }, - "success": { - "description": "Whether the patch was applied successfully.", - "type": "boolean" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_end" - ], - "title": "PatchApplyEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "status", - "stderr", - "stdout", - "success", - "type" - ], - "title": "PatchApplyEndEventMsg", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "turn_diff" - ], - "title": "TurnDiffEventMsgType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "TurnDiffEventMsg", - "type": "object" - }, - { - "description": "Response to GetHistoryEntryRequest.", - "properties": { - "entry": { - "anyOf": [ - { - "$ref": "#/definitions/HistoryEntry" - }, - { - "type": "null" - } - ], - "description": "The entry at the requested offset, if available and parseable." - }, - "log_id": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "offset": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "get_history_entry_response" - ], - "title": "GetHistoryEntryResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "log_id", - "offset", - "type" - ], - "title": "GetHistoryEntryResponseEventMsg", - "type": "object" - }, - { - "description": "List of MCP tools available to the agent.", - "properties": { - "auth_statuses": { - "additionalProperties": { - "$ref": "#/definitions/McpAuthStatus" - }, - "description": "Authentication status for each configured MCP server.", - "type": "object" - }, - "resource_templates": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/ResourceTemplate" - }, - "type": "array" - }, - "description": "Known resource templates grouped by server name.", - "type": "object" - }, - "resources": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/Resource" - }, - "type": "array" - }, - "description": "Known resources grouped by server name.", - "type": "object" - }, - "tools": { - "additionalProperties": { - "$ref": "#/definitions/Tool" - }, - "description": "Fully qualified tool name -> tool definition.", - "type": "object" - }, - "type": { - "enum": [ - "mcp_list_tools_response" - ], - "title": "McpListToolsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "auth_statuses", - "resource_templates", - "resources", - "tools", - "type" - ], - "title": "McpListToolsResponseEventMsg", - "type": "object" - }, - { - "description": "List of custom prompts available to the agent.", - "properties": { - "custom_prompts": { - "items": { - "$ref": "#/definitions/CustomPrompt" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_custom_prompts_response" - ], - "title": "ListCustomPromptsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "custom_prompts", - "type" - ], - "title": "ListCustomPromptsResponseEventMsg", - "type": "object" - }, - { - "description": "List of skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/SkillsListEntry" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_skills_response" - ], - "title": "ListSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "List of remote skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_remote_skills_response" - ], - "title": "ListRemoteSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListRemoteSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "Remote skill downloaded to local cache.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "remote_skill_downloaded" - ], - "title": "RemoteSkillDownloadedEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "name", - "path", - "type" - ], - "title": "RemoteSkillDownloadedEventMsg", - "type": "object" - }, - { - "description": "Notification that skill data may have been updated and clients may want to reload.", - "properties": { - "type": { - "enum": [ - "skills_update_available" - ], - "title": "SkillsUpdateAvailableEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SkillsUpdateAvailableEventMsg", - "type": "object" - }, - { - "properties": { - "explanation": { - "default": null, - "description": "Arguments for the `update_plan` todo/checklist tool (not plan mode).", - "type": [ - "string", - "null" - ] - }, - "plan": { - "items": { - "$ref": "#/definitions/PlanItemArg" - }, - "type": "array" - }, - "type": { - "enum": [ - "plan_update" - ], - "title": "PlanUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "plan", - "type" - ], - "title": "PlanUpdateEventMsg", - "type": "object" - }, - { - "properties": { - "reason": { - "$ref": "#/definitions/TurnAbortReason" - }, - "turn_id": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "turn_aborted" - ], - "title": "TurnAbortedEventMsgType", - "type": "string" - } - }, - "required": [ - "reason", - "type" - ], - "title": "TurnAbortedEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is shutting down.", - "properties": { - "type": { - "enum": [ - "shutdown_complete" - ], - "title": "ShutdownCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ShutdownCompleteEventMsg", - "type": "object" - }, - { - "description": "Entered review mode.", - "properties": { - "target": { - "$ref": "#/definitions/ReviewTarget" - }, - "type": { - "enum": [ - "entered_review_mode" - ], - "title": "EnteredReviewModeEventMsgType", - "type": "string" - }, - "user_facing_hint": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "target", - "type" - ], - "title": "EnteredReviewModeEventMsg", - "type": "object" - }, - { - "description": "Exited review mode with an optional final result to apply.", - "properties": { - "review_output": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewOutputEvent" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "exited_review_mode" - ], - "title": "ExitedReviewModeEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExitedReviewModeEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/ResponseItem" - }, - "type": { - "enum": [ - "raw_response_item" - ], - "title": "RawResponseItemEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "type" - ], - "title": "RawResponseItemEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_started" - ], - "title": "ItemStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemStartedEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_completed" - ], - "title": "ItemCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemCompletedEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_content_delta" - ], - "title": "AgentMessageContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "AgentMessageContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "plan_delta" - ], - "title": "PlanDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "PlanDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_content_delta" - ], - "title": "ReasoningContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "content_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_raw_content_delta" - ], - "title": "ReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_spawn_begin" - ], - "title": "CollabAgentSpawnBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "type" - ], - "title": "CollabAgentSpawnBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "new_agent_nickname": { - "description": "Optional nickname assigned to the new agent.", - "type": [ - "string", - "null" - ] - }, - "new_agent_role": { - "description": "Optional role assigned to the new agent.", - "type": [ - "string", - "null" - ] - }, - "new_thread_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ], - "description": "Thread ID of the newly spawned agent, if it was created." - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the new agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_spawn_end" - ], - "title": "CollabAgentSpawnEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentSpawnEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_interaction_begin" - ], - "title": "CollabAgentInteractionBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabAgentInteractionBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_interaction_end" - ], - "title": "CollabAgentInteractionEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentInteractionEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting begin.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "receiver_agents": { - "description": "Optional nicknames/roles for receivers.", - "items": { - "$ref": "#/definitions/CollabAgentRef" - }, - "type": "array" - }, - "receiver_thread_ids": { - "description": "Thread ID of the receivers.", - "items": { - "$ref": "#/definitions/ThreadId" - }, - "type": "array" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_waiting_begin" - ], - "title": "CollabWaitingBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_ids", - "sender_thread_id", - "type" - ], - "title": "CollabWaitingBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting end.", - "properties": { - "agent_statuses": { - "description": "Optional receiver metadata paired with final statuses.", - "items": { - "$ref": "#/definitions/CollabAgentStatusEntry" - }, - "type": "array" - }, - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "statuses": { - "additionalProperties": { - "$ref": "#/definitions/AgentStatus" - }, - "description": "Last known status of the receiver agents reported to the sender agent.", - "type": "object" - }, - "type": { - "enum": [ - "collab_waiting_end" - ], - "title": "CollabWaitingEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "sender_thread_id", - "statuses", - "type" - ], - "title": "CollabWaitingEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_close_begin" - ], - "title": "CollabCloseBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabCloseBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent before the close." - }, - "type": { - "enum": [ - "collab_close_end" - ], - "title": "CollabCloseEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabCloseEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_resume_begin" - ], - "title": "CollabResumeBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabResumeBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent after resume." - }, - "type": { - "enum": [ - "collab_resume_end" - ], - "title": "CollabResumeEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabResumeEndEventMsg", - "type": "object" - } - ] - }, - "ExecCommandSource": { - "enum": [ - "agent", - "user_shell", - "unified_exec_startup", - "unified_exec_interaction" - ], - "type": "string" - }, - "ExecCommandStatus": { - "enum": [ - "completed", - "failed", - "declined" - ], - "type": "string" - }, - "ExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" - }, - "FileChange": { - "oneOf": [ - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "add" - ], - "title": "AddFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "AddFileChange", - "type": "object" - }, - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "delete" - ], - "title": "DeleteFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "DeleteFileChange", - "type": "object" - }, - { - "properties": { - "move_path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "update" - ], - "title": "UpdateFileChangeType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "UpdateFileChange", - "type": "object" - } - ] - }, - "FunctionCallOutputBody": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" - }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, - "HistoryEntry": { - "properties": { - "conversation_id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "ts": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "conversation_id", - "text", - "ts" - ], - "type": "object" - }, - "LocalShellAction": { - "oneOf": [ - { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "exec" - ], - "title": "ExecLocalShellActionType", - "type": "string" - }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "command", - "type" - ], - "title": "ExecLocalShellAction", - "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "McpAuthStatus": { - "enum": [ - "unsupported", - "not_logged_in", - "bearer_token", - "o_auth" - ], - "type": "string" - }, - "McpInvocation": { - "properties": { - "arguments": { - "description": "Arguments to the tool call." - }, - "server": { - "description": "Name of the MCP server as defined in the config.", - "type": "string" - }, - "tool": { - "description": "Name of the tool as given by the MCP server.", - "type": "string" - } - }, - "required": [ - "server", - "tool" - ], - "type": "object" - }, - "McpStartupFailure": { - "properties": { - "error": { - "type": "string" - }, - "server": { - "type": "string" - } - }, - "required": [ - "error", - "server" - ], - "type": "object" - }, - "McpStartupStatus": { - "oneOf": [ - { - "properties": { - "state": { - "enum": [ - "starting" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus", - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "ready" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus2", - "type": "object" - }, - { - "properties": { - "error": { - "type": "string" - }, - "state": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "error", - "state" - ], - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "cancelled" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus3", - "type": "object" - } - ] - }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], - "type": "string" - }, - { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" - ], - "type": "string" - }, - "ModelRerouteReason": { - "enum": [ - "high_risk_cyber_activity" - ], - "type": "string" - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "NetworkApprovalContext": { - "properties": { - "host": { - "type": "string" - }, - "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" - } - }, - "required": [ - "host", - "protocol" - ], - "type": "object" - }, - "NetworkApprovalProtocol": { - "enum": [ - "http", - "https", - "socks5_tcp", - "socks5_udp" - ], - "type": "string" - }, - "ParsedCommand": { - "oneOf": [ - { - "properties": { - "cmd": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", - "type": "string" - }, - "type": { - "enum": [ - "read" - ], - "title": "ReadParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "name", - "path", - "type" - ], - "title": "ReadParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "list_files" - ], - "title": "ListFilesParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ListFilesParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "SearchParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "unknown" - ], - "title": "UnknownParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "UnknownParsedCommand", - "type": "object" - } - ] - }, - "PatchApplyStatus": { - "enum": [ - "completed", - "failed", - "declined" - ], - "type": "string" - }, - "PlanItemArg": { - "additionalProperties": false, - "properties": { - "status": { - "$ref": "#/definitions/StepStatus" - }, - "step": { - "type": "string" - } - }, - "required": [ - "status", - "step" - ], - "type": "object" - }, - "PlanType": { - "enum": [ - "free", - "go", - "plus", - "pro", - "team", - "business", - "enterprise", - "edu", - "unknown" - ], - "type": "string" - }, - "RateLimitSnapshot": { - "properties": { - "credits": { - "anyOf": [ - { - "$ref": "#/definitions/CreditsSnapshot" - }, - { - "type": "null" - } - ] - }, - "limit_id": { - "type": [ - "string", - "null" - ] - }, - "limit_name": { - "type": [ - "string", - "null" - ] - }, - "plan_type": { - "anyOf": [ - { - "$ref": "#/definitions/PlanType" - }, - { - "type": "null" - } - ] - }, - "primary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - }, - "secondary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RateLimitWindow": { - "properties": { - "resets_at": { - "description": "Unix timestamp (seconds since epoch) when the window resets.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "used_percent": { - "description": "Percentage (0-100) of the window that has been consumed.", - "format": "double", - "type": "number" - }, - "window_minutes": { - "description": "Rolling window duration, in minutes.", - "format": "int64", - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "used_percent" - ], - "type": "object" - }, - "ReadOnlyAccess": { - "description": "Determines how read-only file access is granted inside a restricted sandbox.", - "oneOf": [ - { - "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", - "properties": { - "include_platform_defaults": { - "default": true, - "description": "Include built-in platform read roots required for basic process execution.", - "type": "boolean" - }, - "readable_roots": { - "description": "Additional absolute roots that should be readable.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "description": "Allow unrestricted file reads.", - "properties": { - "type": { - "enum": [ - "full-access" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, - "RealtimeAudioFrame": { - "properties": { - "data": { - "type": "string" - }, - "num_channels": { - "format": "uint16", - "minimum": 0.0, - "type": "integer" - }, - "sample_rate": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "samples_per_channel": { - "format": "uint32", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "data", - "num_channels", - "sample_rate" - ], - "type": "object" - }, - "RealtimeEvent": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "SessionCreated": { - "properties": { - "session_id": { - "type": "string" - } - }, - "required": [ - "session_id" - ], - "type": "object" - } - }, - "required": [ - "SessionCreated" - ], - "title": "SessionCreatedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "SessionUpdated": { - "properties": { - "backend_prompt": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "SessionUpdated" - ], - "title": "SessionUpdatedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "AudioOut": { - "$ref": "#/definitions/RealtimeAudioFrame" - } - }, - "required": [ - "AudioOut" - ], - "title": "AudioOutRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "ConversationItemAdded": true - }, - "required": [ - "ConversationItemAdded" - ], - "title": "ConversationItemAddedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "Error": { - "type": "string" - } - }, - "required": [ - "Error" - ], - "title": "ErrorRealtimeEvent", - "type": "object" - } - ] - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, - "RequestId": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "int64", - "type": "integer" - } - ], - "description": "ID of a request, which can be either a string or an integer." - }, - "RequestUserInputQuestion": { - "properties": { - "header": { - "type": "string" - }, - "id": { - "type": "string" - }, - "isOther": { - "default": false, - "type": "boolean" - }, - "isSecret": { - "default": false, - "type": "boolean" - }, - "options": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestionOption" - }, - "type": [ - "array", - "null" - ] - }, - "question": { - "type": "string" - } - }, - "required": [ - "header", - "id", - "question" - ], - "type": "object" - }, - "RequestUserInputQuestionOption": { - "properties": { - "description": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "description", - "label" - ], - "type": "object" - }, - "Resource": { - "description": "A known resource that the server is capable of reading.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "size": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "name", - "uri" - ], - "type": "object" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server.", - "properties": { - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "type": "string" - } - }, - "required": [ - "name", - "uriTemplate" - ], - "type": "object" - }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" - }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "type": "string" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction" - ], - "title": "CompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionResponseItem", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" - } - ] - }, - "Result_of_CallToolResult_or_String": { - "oneOf": [ - { - "properties": { - "Ok": { - "$ref": "#/definitions/CallToolResult" - } - }, - "required": [ - "Ok" - ], - "title": "OkResult_of_CallToolResult_or_String", - "type": "object" - }, - { - "properties": { - "Err": { - "type": "string" - } - }, - "required": [ - "Err" - ], - "title": "ErrResult_of_CallToolResult_or_String", - "type": "object" - } - ] - }, - "ReviewCodeLocation": { - "description": "Location of the code related to a review finding.", - "properties": { - "absolute_file_path": { - "type": "string" - }, - "line_range": { - "$ref": "#/definitions/ReviewLineRange" - } - }, - "required": [ - "absolute_file_path", - "line_range" - ], - "type": "object" - }, - "ReviewFinding": { - "description": "A single review finding describing an observed issue or recommendation.", - "properties": { - "body": { - "type": "string" - }, - "code_location": { - "$ref": "#/definitions/ReviewCodeLocation" - }, - "confidence_score": { - "format": "float", - "type": "number" - }, - "priority": { - "format": "int32", - "type": "integer" - }, - "title": { - "type": "string" - } - }, - "required": [ - "body", - "code_location", - "confidence_score", - "priority", - "title" - ], - "type": "object" - }, - "ReviewLineRange": { - "description": "Inclusive line range in a file associated with the finding.", - "properties": { - "end": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "ReviewOutputEvent": { - "description": "Structured review result produced by a child review session.", - "properties": { - "findings": { - "items": { - "$ref": "#/definitions/ReviewFinding" - }, - "type": "array" - }, - "overall_confidence_score": { - "format": "float", - "type": "number" - }, - "overall_correctness": { - "type": "string" - }, - "overall_explanation": { - "type": "string" - } - }, - "required": [ - "findings", - "overall_confidence_score", - "overall_correctness", - "overall_explanation" - ], - "type": "object" - }, - "ReviewTarget": { - "oneOf": [ - { - "description": "Review the working tree: staged, unstaged, and untracked files.", - "properties": { - "type": { - "enum": [ - "uncommittedChanges" - ], - "title": "UncommittedChangesReviewTargetType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UncommittedChangesReviewTarget", - "type": "object" - }, - { - "description": "Review changes between the current branch and the given base branch.", - "properties": { - "branch": { - "type": "string" - }, - "type": { - "enum": [ - "baseBranch" - ], - "title": "BaseBranchReviewTargetType", - "type": "string" - } - }, - "required": [ - "branch", - "type" - ], - "title": "BaseBranchReviewTarget", - "type": "object" - }, - { - "description": "Review the changes introduced by a specific commit.", - "properties": { - "sha": { - "type": "string" - }, - "title": { - "description": "Optional human-readable label (e.g., commit subject) for UIs.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "commit" - ], - "title": "CommitReviewTargetType", - "type": "string" - } - }, - "required": [ - "sha", - "type" - ], - "title": "CommitReviewTarget", - "type": "object" - }, - { - "description": "Arbitrary instructions provided by the user.", - "properties": { - "instructions": { - "type": "string" - }, - "type": { - "enum": [ - "custom" - ], - "title": "CustomReviewTargetType", - "type": "string" - } - }, - "required": [ - "instructions", - "type" - ], - "title": "CustomReviewTarget", - "type": "object" - } - ] - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access configuration.", - "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "read_only_access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - }, - "SessionNetworkProxyRuntime": { - "properties": { - "admin_addr": { - "type": "string" - }, - "http_addr": { - "type": "string" - }, - "socks_addr": { - "type": "string" - } - }, - "required": [ - "admin_addr", - "http_addr", - "socks_addr" - ], - "type": "object" - }, - "SkillDependencies": { - "properties": { - "tools": { - "items": { - "$ref": "#/definitions/SkillToolDependency" - }, - "type": "array" - } - }, - "required": [ - "tools" - ], - "type": "object" - }, - "SkillErrorInfo": { - "properties": { - "message": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "message", - "path" - ], - "type": "object" - }, - "SkillInterface": { - "properties": { - "brand_color": { - "type": [ - "string", - "null" - ] - }, - "default_prompt": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "icon_large": { - "type": [ - "string", - "null" - ] - }, - "icon_small": { - "type": [ - "string", - "null" - ] - }, - "short_description": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "SkillMetadata": { - "properties": { - "dependencies": { - "anyOf": [ - { - "$ref": "#/definitions/SkillDependencies" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "interface": { - "anyOf": [ - { - "$ref": "#/definitions/SkillInterface" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "scope": { - "$ref": "#/definitions/SkillScope" - }, - "short_description": { - "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "description", - "enabled", - "name", - "path", - "scope" - ], - "type": "object" - }, - "SkillScope": { - "enum": [ - "user", - "repo", - "system", - "admin" - ], - "type": "string" - }, - "SkillToolDependency": { - "properties": { - "command": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "transport": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "value" - ], - "type": "object" - }, - "SkillsListEntry": { - "properties": { - "cwd": { - "type": "string" - }, - "errors": { - "items": { - "$ref": "#/definitions/SkillErrorInfo" - }, - "type": "array" - }, - "skills": { - "items": { - "$ref": "#/definitions/SkillMetadata" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "errors", - "skills" - ], - "type": "object" - }, - "StepStatus": { - "enum": [ - "pending", - "in_progress", - "completed" - ], - "type": "string" - }, - "TextElement": { - "properties": { - "byte_range": { - "allOf": [ - { - "$ref": "#/definitions/ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byte_range" - ], - "type": "object" - }, - "ThreadId": { - "type": "string" - }, - "TokenUsage": { - "properties": { - "cached_input_tokens": { - "format": "int64", - "type": "integer" - }, - "input_tokens": { - "format": "int64", - "type": "integer" - }, - "output_tokens": { - "format": "int64", - "type": "integer" - }, - "reasoning_output_tokens": { - "format": "int64", - "type": "integer" - }, - "total_tokens": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "cached_input_tokens", - "input_tokens", - "output_tokens", - "reasoning_output_tokens", - "total_tokens" - ], - "type": "object" - }, - "TokenUsageInfo": { - "properties": { - "last_token_usage": { - "$ref": "#/definitions/TokenUsage" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "total_token_usage": { - "$ref": "#/definitions/TokenUsage" - } - }, - "required": [ - "last_token_usage", - "total_token_usage" - ], - "type": "object" - }, - "Tool": { - "description": "Definition for a tool the client can call.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "inputSchema": true, - "name": { - "type": "string" - }, - "outputSchema": true, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "inputSchema", - "name" - ], - "type": "object" - }, - "TurnAbortReason": { - "enum": [ - "interrupted", - "replaced", - "review_ended" - ], - "type": "string" - }, - "TurnItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/UserInput" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "type": { - "enum": [ - "UserMessage" - ], - "title": "UserMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "UserMessageTurnItem", - "type": "object" - }, - { - "description": "Assistant-authored message payload used in turn-item streams.\n\n`phase` is optional because not all providers/models emit it. Consumers should use it when present, but retain legacy completion semantics when it is `None`.", - "properties": { - "content": { - "items": { - "$ref": "#/definitions/AgentMessageContent" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." - }, - "type": { - "enum": [ - "AgentMessage" - ], - "title": "AgentMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "AgentMessageTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Plan" - ], - "title": "PlanTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "text", - "type" - ], - "title": "PlanTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "raw_content": { - "default": [], - "items": { - "type": "string" - }, - "type": "array" - }, - "summary_text": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "Reasoning" - ], - "title": "ReasoningTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary_text", - "type" - ], - "title": "ReasoningTurnItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "WebSearch" - ], - "title": "WebSearchTurnItemType", - "type": "string" - } - }, - "required": [ - "action", - "id", - "query", - "type" - ], - "title": "WebSearchTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "type": { - "enum": [ - "ContextCompaction" - ], - "title": "ContextCompactionTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ContextCompactionTurnItem", - "type": "object" - } - ] - }, - "UserInput": { - "description": "User input", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` that should be treated as special elements. These are byte ranges into the UTF-8 `text` buffer and are used to render or persist rich input markers (e.g., image placeholders) across history and resume without mutating the literal text.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextUserInputType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextUserInput", - "type": "object" - }, - { - "description": "Pre‑encoded data: URI image.", - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageUserInputType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "ImageUserInput", - "type": "object" - }, - { - "description": "Local image path provided by the user. This will be converted to an `Image` variant (base64 data URL) during request serialization.", - "properties": { - "path": { - "type": "string" - }, - "type": { - "enum": [ - "local_image" - ], - "title": "LocalImageUserInputType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "LocalImageUserInput", - "type": "object" - }, - { - "description": "Skill selected by the user (name + path to SKILL.md).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "skill" - ], - "title": "SkillUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "SkillUserInput", - "type": "object" - }, - { - "description": "Explicit mention selected by the user (name + app://connector id).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "mention" - ], - "title": "MentionUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "MentionUserInput", - "type": "object" - } - ] - }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "model", - "rolloutPath" - ], - "title": "ForkConversationResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/GetAuthStatusParams.json b/codex-rs/app-server-protocol/schema/json/v1/GetAuthStatusParams.json deleted file mode 100644 index 0b21fd765d..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/GetAuthStatusParams.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "includeToken": { - "type": [ - "boolean", - "null" - ] - }, - "refreshToken": { - "type": [ - "boolean", - "null" - ] - } - }, - "title": "GetAuthStatusParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/GetAuthStatusResponse.json b/codex-rs/app-server-protocol/schema/json/v1/GetAuthStatusResponse.json deleted file mode 100644 index 7a605453d4..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/GetAuthStatusResponse.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AuthMode": { - "description": "Authentication mode for OpenAI-backed providers.", - "oneOf": [ - { - "description": "OpenAI API key provided by the caller and stored by Codex.", - "enum": [ - "apikey" - ], - "type": "string" - }, - { - "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", - "enum": [ - "chatgpt" - ], - "type": "string" - }, - { - "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", - "enum": [ - "chatgptAuthTokens" - ], - "type": "string" - } - ] - } - }, - "properties": { - "authMethod": { - "anyOf": [ - { - "$ref": "#/definitions/AuthMode" - }, - { - "type": "null" - } - ] - }, - "authToken": { - "type": [ - "string", - "null" - ] - }, - "requiresOpenaiAuth": { - "type": [ - "boolean", - "null" - ] - } - }, - "title": "GetAuthStatusResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/GetConversationSummaryParams.json b/codex-rs/app-server-protocol/schema/json/v1/GetConversationSummaryParams.json deleted file mode 100644 index aa6726cded..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/GetConversationSummaryParams.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "anyOf": [ - { - "properties": { - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "rolloutPath" - ], - "title": "RolloutPathv1::GetConversationSummaryParams", - "type": "object" - }, - { - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "conversationId" - ], - "title": "ConversationIdv1::GetConversationSummaryParams", - "type": "object" - } - ], - "definitions": { - "ThreadId": { - "type": "string" - } - }, - "title": "GetConversationSummaryParams" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/GetConversationSummaryResponse.json b/codex-rs/app-server-protocol/schema/json/v1/GetConversationSummaryResponse.json deleted file mode 100644 index 13cdf0b0f8..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/GetConversationSummaryResponse.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ConversationGitInfo": { - "properties": { - "branch": { - "type": [ - "string", - "null" - ] - }, - "origin_url": { - "type": [ - "string", - "null" - ] - }, - "sha": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "ConversationSummary": { - "properties": { - "cliVersion": { - "type": "string" - }, - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "cwd": { - "type": "string" - }, - "gitInfo": { - "anyOf": [ - { - "$ref": "#/definitions/ConversationGitInfo" - }, - { - "type": "null" - } - ] - }, - "modelProvider": { - "type": "string" - }, - "path": { - "type": "string" - }, - "preview": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/SessionSource" - }, - "timestamp": { - "type": [ - "string", - "null" - ] - }, - "updatedAt": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "cliVersion", - "conversationId", - "cwd", - "modelProvider", - "path", - "preview", - "source" - ], - "type": "object" - }, - "SessionSource": { - "oneOf": [ - { - "enum": [ - "cli", - "vscode", - "exec", - "mcp", - "unknown" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "subagent": { - "$ref": "#/definitions/SubAgentSource" - } - }, - "required": [ - "subagent" - ], - "title": "SubagentSessionSource", - "type": "object" - } - ] - }, - "SubAgentSource": { - "oneOf": [ - { - "enum": [ - "review", - "compact", - "memory_consolidation" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "thread_spawn": { - "properties": { - "agent_nickname": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "agent_role": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "depth": { - "format": "int32", - "type": "integer" - }, - "parent_thread_id": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "depth", - "parent_thread_id" - ], - "type": "object" - } - }, - "required": [ - "thread_spawn" - ], - "title": "ThreadSpawnSubAgentSource", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "other": { - "type": "string" - } - }, - "required": [ - "other" - ], - "title": "OtherSubAgentSource", - "type": "object" - } - ] - }, - "ThreadId": { - "type": "string" - } - }, - "properties": { - "summary": { - "$ref": "#/definitions/ConversationSummary" - } - }, - "required": [ - "summary" - ], - "title": "GetConversationSummaryResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/GetUserAgentResponse.json b/codex-rs/app-server-protocol/schema/json/v1/GetUserAgentResponse.json deleted file mode 100644 index c041282c8f..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/GetUserAgentResponse.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "userAgent": { - "type": "string" - } - }, - "required": [ - "userAgent" - ], - "title": "GetUserAgentResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/GetUserSavedConfigResponse.json b/codex-rs/app-server-protocol/schema/json/v1/GetUserSavedConfigResponse.json deleted file mode 100644 index 2f6d8b3b5f..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/GetUserSavedConfigResponse.json +++ /dev/null @@ -1,366 +0,0 @@ -{ - "$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" - }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "ForcedLoginMethod": { - "enum": [ - "chatgpt", - "api" - ], - "type": "string" - }, - "Profile": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "chatgptBaseUrl": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelProvider": { - "type": [ - "string", - "null" - ] - }, - "modelReasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "modelReasoningSummary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "modelVerbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningSummary": { - "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", - "oneOf": [ - { - "enum": [ - "auto", - "concise", - "detailed" - ], - "type": "string" - }, - { - "description": "Option to disable reasoning summaries.", - "enum": [ - "none" - ], - "type": "string" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "SandboxMode": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - }, - "SandboxSettings": { - "properties": { - "excludeSlashTmp": { - "type": [ - "boolean", - "null" - ] - }, - "excludeTmpdirEnvVar": { - "type": [ - "boolean", - "null" - ] - }, - "networkAccess": { - "type": [ - "boolean", - "null" - ] - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "type": "object" - }, - "Tools": { - "properties": { - "viewImage": { - "type": [ - "boolean", - "null" - ] - }, - "webSearch": { - "type": [ - "boolean", - "null" - ] - } - }, - "type": "object" - }, - "UserSavedConfig": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "forcedChatgptWorkspaceId": { - "type": [ - "string", - "null" - ] - }, - "forcedLoginMethod": { - "anyOf": [ - { - "$ref": "#/definitions/ForcedLoginMethod" - }, - { - "type": "null" - } - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelReasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "modelReasoningSummary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "modelVerbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/Profile" - }, - "type": "object" - }, - "sandboxMode": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode" - }, - { - "type": "null" - } - ] - }, - "sandboxSettings": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxSettings" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/Tools" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "profiles" - ], - "type": "object" - }, - "Verbosity": { - "description": "Controls output length/detail on GPT-5 models via the Responses API. Serialized with lowercase values to match the OpenAI API.", - "enum": [ - "low", - "medium", - "high" - ], - "type": "string" - } - }, - "properties": { - "config": { - "$ref": "#/definitions/UserSavedConfig" - } - }, - "required": [ - "config" - ], - "title": "GetUserSavedConfigResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteParams.json b/codex-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteParams.json deleted file mode 100644 index 51640965ca..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteParams.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "cwd": { - "type": "string" - } - }, - "required": [ - "cwd" - ], - "title": "GitDiffToRemoteParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteResponse.json b/codex-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteResponse.json deleted file mode 100644 index fd59b80e94..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteResponse.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "GitSha": { - "type": "string" - } - }, - "properties": { - "diff": { - "type": "string" - }, - "sha": { - "$ref": "#/definitions/GitSha" - } - }, - "required": [ - "diff", - "sha" - ], - "title": "GitDiffToRemoteResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/InterruptConversationParams.json b/codex-rs/app-server-protocol/schema/json/v1/InterruptConversationParams.json deleted file mode 100644 index 4fdd221fca..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/InterruptConversationParams.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ThreadId": { - "type": "string" - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "conversationId" - ], - "title": "InterruptConversationParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/InterruptConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/InterruptConversationResponse.json deleted file mode 100644 index 5d2ddf3e40..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/InterruptConversationResponse.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "TurnAbortReason": { - "enum": [ - "interrupted", - "replaced", - "review_ended" - ], - "type": "string" - } - }, - "properties": { - "abortReason": { - "$ref": "#/definitions/TurnAbortReason" - } - }, - "required": [ - "abortReason" - ], - "title": "InterruptConversationResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/ListConversationsParams.json b/codex-rs/app-server-protocol/schema/json/v1/ListConversationsParams.json deleted file mode 100644 index 9ac05602c4..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/ListConversationsParams.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "modelProviders": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "pageSize": { - "format": "uint", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "title": "ListConversationsParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/ListConversationsResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ListConversationsResponse.json deleted file mode 100644 index 2e3ab7e0dc..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/ListConversationsResponse.json +++ /dev/null @@ -1,199 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ConversationGitInfo": { - "properties": { - "branch": { - "type": [ - "string", - "null" - ] - }, - "origin_url": { - "type": [ - "string", - "null" - ] - }, - "sha": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "ConversationSummary": { - "properties": { - "cliVersion": { - "type": "string" - }, - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "cwd": { - "type": "string" - }, - "gitInfo": { - "anyOf": [ - { - "$ref": "#/definitions/ConversationGitInfo" - }, - { - "type": "null" - } - ] - }, - "modelProvider": { - "type": "string" - }, - "path": { - "type": "string" - }, - "preview": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/SessionSource" - }, - "timestamp": { - "type": [ - "string", - "null" - ] - }, - "updatedAt": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "cliVersion", - "conversationId", - "cwd", - "modelProvider", - "path", - "preview", - "source" - ], - "type": "object" - }, - "SessionSource": { - "oneOf": [ - { - "enum": [ - "cli", - "vscode", - "exec", - "mcp", - "unknown" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "subagent": { - "$ref": "#/definitions/SubAgentSource" - } - }, - "required": [ - "subagent" - ], - "title": "SubagentSessionSource", - "type": "object" - } - ] - }, - "SubAgentSource": { - "oneOf": [ - { - "enum": [ - "review", - "compact", - "memory_consolidation" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "thread_spawn": { - "properties": { - "agent_nickname": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "agent_role": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "depth": { - "format": "int32", - "type": "integer" - }, - "parent_thread_id": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "depth", - "parent_thread_id" - ], - "type": "object" - } - }, - "required": [ - "thread_spawn" - ], - "title": "ThreadSpawnSubAgentSource", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "other": { - "type": "string" - } - }, - "required": [ - "other" - ], - "title": "OtherSubAgentSource", - "type": "object" - } - ] - }, - "ThreadId": { - "type": "string" - } - }, - "properties": { - "items": { - "items": { - "$ref": "#/definitions/ConversationSummary" - }, - "type": "array" - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "items" - ], - "title": "ListConversationsResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/LoginApiKeyParams.json b/codex-rs/app-server-protocol/schema/json/v1/LoginApiKeyParams.json deleted file mode 100644 index b23ce5548f..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/LoginApiKeyParams.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "apiKey": { - "type": "string" - } - }, - "required": [ - "apiKey" - ], - "title": "LoginApiKeyParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/LoginApiKeyResponse.json b/codex-rs/app-server-protocol/schema/json/v1/LoginApiKeyResponse.json deleted file mode 100644 index cba1fe3c40..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/LoginApiKeyResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "LoginApiKeyResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/LoginChatGptCompleteNotification.json b/codex-rs/app-server-protocol/schema/json/v1/LoginChatGptCompleteNotification.json deleted file mode 100644 index ae34de2f42..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/LoginChatGptCompleteNotification.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Deprecated in favor of AccountLoginCompletedNotification.", - "properties": { - "error": { - "type": [ - "string", - "null" - ] - }, - "loginId": { - "type": "string" - }, - "success": { - "type": "boolean" - } - }, - "required": [ - "loginId", - "success" - ], - "title": "LoginChatGptCompleteNotification", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/LoginChatGptResponse.json b/codex-rs/app-server-protocol/schema/json/v1/LoginChatGptResponse.json deleted file mode 100644 index 9ecf3cdbb9..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/LoginChatGptResponse.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "authUrl": { - "type": "string" - }, - "loginId": { - "type": "string" - } - }, - "required": [ - "authUrl", - "loginId" - ], - "title": "LoginChatGptResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/LogoutChatGptResponse.json b/codex-rs/app-server-protocol/schema/json/v1/LogoutChatGptResponse.json deleted file mode 100644 index 449cfc5fab..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/LogoutChatGptResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "LogoutChatGptResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/NewConversationParams.json b/codex-rs/app-server-protocol/schema/json/v1/NewConversationParams.json deleted file mode 100644 index c9c8caaa26..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/NewConversationParams.json +++ /dev/null @@ -1,161 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "SandboxMode": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - } - }, - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "baseInstructions": { - "type": [ - "string", - "null" - ] - }, - "compactPrompt": { - "type": [ - "string", - "null" - ] - }, - "config": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "developerInstructions": { - "type": [ - "string", - "null" - ] - }, - "includeApplyPatchTool": { - "type": [ - "boolean", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelProvider": { - "type": [ - "string", - "null" - ] - }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "sandbox": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode" - }, - { - "type": "null" - } - ] - } - }, - "title": "NewConversationParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/NewConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/NewConversationResponse.json deleted file mode 100644 index 8030d8113f..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/NewConversationResponse.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ThreadId": { - "type": "string" - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "model": { - "type": "string" - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "model", - "rolloutPath" - ], - "title": "NewConversationResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/RemoveConversationListenerParams.json b/codex-rs/app-server-protocol/schema/json/v1/RemoveConversationListenerParams.json deleted file mode 100644 index 990b8ff0c4..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/RemoveConversationListenerParams.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "subscriptionId": { - "type": "string" - } - }, - "required": [ - "subscriptionId" - ], - "title": "RemoveConversationListenerParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/RemoveConversationSubscriptionResponse.json b/codex-rs/app-server-protocol/schema/json/v1/RemoveConversationSubscriptionResponse.json deleted file mode 100644 index 8efe40d430..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/RemoveConversationSubscriptionResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "RemoveConversationSubscriptionResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json deleted file mode 100644 index a45e1e60ee..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json +++ /dev/null @@ -1,984 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputBody": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" - }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, - "LocalShellAction": { - "oneOf": [ - { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "exec" - ], - "title": "ExecLocalShellActionType", - "type": "string" - }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "command", - "type" - ], - "title": "ExecLocalShellAction", - "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], - "type": "string" - }, - { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "NewConversationParams": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "baseInstructions": { - "type": [ - "string", - "null" - ] - }, - "compactPrompt": { - "type": [ - "string", - "null" - ] - }, - "config": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "developerInstructions": { - "type": [ - "string", - "null" - ] - }, - "includeApplyPatchTool": { - "type": [ - "boolean", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelProvider": { - "type": [ - "string", - "null" - ] - }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "sandbox": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" - }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "type": "string" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction" - ], - "title": "CompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionResponseItem", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" - } - ] - }, - "SandboxMode": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - }, - "ThreadId": { - "type": "string" - }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] - } - }, - "properties": { - "conversationId": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history": { - "items": { - "$ref": "#/definitions/ResponseItem" - }, - "type": [ - "array", - "null" - ] - }, - "overrides": { - "anyOf": [ - { - "$ref": "#/definitions/NewConversationParams" - }, - { - "type": "null" - } - ] - }, - "path": { - "type": [ - "string", - "null" - ] - } - }, - "title": "ResumeConversationParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json deleted file mode 100644 index 3ce7593460..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ /dev/null @@ -1,5776 +0,0 @@ -{ - "$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" - }, - "AgentMessageContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Text" - ], - "title": "TextAgentMessageContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextAgentMessageContent", - "type": "object" - } - ] - }, - "AgentStatus": { - "description": "Agent lifecycle status, derived from emitted events.", - "oneOf": [ - { - "description": "Agent is waiting for initialization.", - "enum": [ - "pending_init" - ], - "type": "string" - }, - { - "description": "Agent is currently running.", - "enum": [ - "running" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Agent is done. Contains the final assistant message.", - "properties": { - "completed": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "completed" - ], - "title": "CompletedAgentStatus", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Agent encountered an error.", - "properties": { - "errored": { - "type": "string" - } - }, - "required": [ - "errored" - ], - "title": "ErroredAgentStatus", - "type": "object" - }, - { - "description": "Agent has been shutdown.", - "enum": [ - "shutdown" - ], - "type": "string" - }, - { - "description": "Agent is not found.", - "enum": [ - "not_found" - ], - "type": "string" - } - ] - }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "CallToolResult": { - "description": "The server's response to a tool call.", - "properties": { - "_meta": true, - "content": { - "items": true, - "type": "array" - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true - }, - "required": [ - "content" - ], - "type": "object" - }, - "CodexErrorInfo": { - "description": "Codex errors that we expose to clients.", - "oneOf": [ - { - "enum": [ - "context_window_exceeded", - "usage_limit_exceeded", - "server_overloaded", - "internal_server_error", - "unauthorized", - "bad_request", - "sandbox_error", - "thread_rollback_failed", - "other" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "http_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "http_connection_failed" - ], - "title": "HttpConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Failed to connect to the response SSE stream.", - "properties": { - "response_stream_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_connection_failed" - ], - "title": "ResponseStreamConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "The response SSE stream disconnected in the middle of a turnbefore completion.", - "properties": { - "response_stream_disconnected": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_disconnected" - ], - "title": "ResponseStreamDisconnectedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Reached the retry limit for responses.", - "properties": { - "response_too_many_failed_attempts": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_too_many_failed_attempts" - ], - "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", - "type": "object" - } - ] - }, - "CollabAgentRef": { - "properties": { - "agent_nickname": { - "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "agent_role": { - "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver/new agent." - } - }, - "required": [ - "thread_id" - ], - "type": "object" - }, - "CollabAgentStatusEntry": { - "properties": { - "agent_nickname": { - "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "agent_role": { - "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the agent." - }, - "thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver/new agent." - } - }, - "required": [ - "status", - "thread_id" - ], - "type": "object" - }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, - "CreditsSnapshot": { - "properties": { - "balance": { - "type": [ - "string", - "null" - ] - }, - "has_credits": { - "type": "boolean" - }, - "unlimited": { - "type": "boolean" - } - }, - "required": [ - "has_credits", - "unlimited" - ], - "type": "object" - }, - "CustomPrompt": { - "properties": { - "argument_hint": { - "type": [ - "string", - "null" - ] - }, - "content": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "content", - "name", - "path" - ], - "type": "object" - }, - "Duration": { - "properties": { - "nanos": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "secs": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "nanos", - "secs" - ], - "type": "object" - }, - "EventMsg": { - "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", - "oneOf": [ - { - "description": "Error while executing a submission", - "properties": { - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "error" - ], - "title": "ErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "ErrorEventMsg", - "type": "object" - }, - { - "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "warning" - ], - "title": "WarningEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "WarningEventMsg", - "type": "object" - }, - { - "description": "Realtime conversation lifecycle start event.", - "properties": { - "session_id": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "realtime_conversation_started" - ], - "title": "RealtimeConversationStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RealtimeConversationStartedEventMsg", - "type": "object" - }, - { - "description": "Realtime conversation streaming payload event.", - "properties": { - "payload": { - "$ref": "#/definitions/RealtimeEvent" - }, - "type": { - "enum": [ - "realtime_conversation_realtime" - ], - "title": "RealtimeConversationRealtimeEventMsgType", - "type": "string" - } - }, - "required": [ - "payload", - "type" - ], - "title": "RealtimeConversationRealtimeEventMsg", - "type": "object" - }, - { - "description": "Realtime conversation lifecycle close event.", - "properties": { - "reason": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "realtime_conversation_closed" - ], - "title": "RealtimeConversationClosedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RealtimeConversationClosedEventMsg", - "type": "object" - }, - { - "description": "Model routing changed from the requested model to a different model.", - "properties": { - "from_model": { - "type": "string" - }, - "reason": { - "$ref": "#/definitions/ModelRerouteReason" - }, - "to_model": { - "type": "string" - }, - "type": { - "enum": [ - "model_reroute" - ], - "title": "ModelRerouteEventMsgType", - "type": "string" - } - }, - "required": [ - "from_model", - "reason", - "to_model", - "type" - ], - "title": "ModelRerouteEventMsg", - "type": "object" - }, - { - "description": "Conversation history was compacted (either automatically or manually).", - "properties": { - "type": { - "enum": [ - "context_compacted" - ], - "title": "ContextCompactedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactedEventMsg", - "type": "object" - }, - { - "description": "Conversation history was rolled back by dropping the last N user turns.", - "properties": { - "num_turns": { - "description": "Number of user turns that were removed from context.", - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "thread_rolled_back" - ], - "title": "ThreadRolledBackEventMsgType", - "type": "string" - } - }, - "required": [ - "num_turns", - "type" - ], - "title": "ThreadRolledBackEventMsg", - "type": "object" - }, - { - "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", - "properties": { - "collaboration_mode_kind": { - "allOf": [ - { - "$ref": "#/definitions/ModeKind" - } - ], - "default": "default" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "task_started" - ], - "title": "TaskStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "turn_id", - "type" - ], - "title": "TaskStartedEventMsg", - "type": "object" - }, - { - "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", - "properties": { - "last_agent_message": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "task_complete" - ], - "title": "TaskCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "turn_id", - "type" - ], - "title": "TaskCompleteEventMsg", - "type": "object" - }, - { - "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", - "properties": { - "info": { - "anyOf": [ - { - "$ref": "#/definitions/TokenUsageInfo" - }, - { - "type": "null" - } - ] - }, - "rate_limits": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitSnapshot" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "token_count" - ], - "title": "TokenCountEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TokenCountEventMsg", - "type": "object" - }, - { - "description": "Agent text output message", - "properties": { - "message": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "default": null - }, - "type": { - "enum": [ - "agent_message" - ], - "title": "AgentMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "AgentMessageEventMsg", - "type": "object" - }, - { - "description": "User/system input message (what was sent to the model)", - "properties": { - "images": { - "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "local_images": { - "default": [], - "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", - "items": { - "type": "string" - }, - "type": "array" - }, - "message": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `message` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "user_message" - ], - "title": "UserMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "UserMessageEventMsg", - "type": "object" - }, - { - "description": "Agent text output delta message", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_delta" - ], - "title": "AgentMessageDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentMessageDeltaEventMsg", - "type": "object" - }, - { - "description": "Reasoning event from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning" - ], - "title": "AgentReasoningEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_delta" - ], - "title": "AgentReasoningDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningDeltaEventMsg", - "type": "object" - }, - { - "description": "Raw chain-of-thought from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content" - ], - "title": "AgentReasoningRawContentEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningRawContentEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning content delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content_delta" - ], - "title": "AgentReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", - "properties": { - "item_id": { - "default": "", - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "type": { - "enum": [ - "agent_reasoning_section_break" - ], - "title": "AgentReasoningSectionBreakEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AgentReasoningSectionBreakEventMsg", - "type": "object" - }, - { - "description": "Ack the client's configure message.", - "properties": { - "approval_policy": { - "allOf": [ - { - "$ref": "#/definitions/AskForApproval" - } - ], - "description": "When to escalate for approval for execution" - }, - "cwd": { - "description": "Working directory that should be treated as the *root* of the session.", - "type": "string" - }, - "forked_from_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history_entry_count": { - "description": "Current number of entries in the history log.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "history_log_id": { - "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initial_messages": { - "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "description": "Tell the client what model is being queried.", - "type": "string" - }, - "model_provider_id": { - "type": "string" - }, - "network_proxy": { - "anyOf": [ - { - "$ref": "#/definitions/SessionNetworkProxyRuntime" - }, - { - "type": "null" - } - ], - "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." - }, - "reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ], - "description": "The effort the model is putting into reasoning about the user's request." - }, - "rollout_path": { - "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", - "type": [ - "string", - "null" - ] - }, - "sandbox_policy": { - "allOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - } - ], - "description": "How to sandbox commands executed in the system" - }, - "session_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "description": "Optional user-facing thread name (may be unset).", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "session_configured" - ], - "title": "SessionConfiguredEventMsgType", - "type": "string" - } - }, - "required": [ - "approval_policy", - "cwd", - "history_entry_count", - "history_log_id", - "model", - "model_provider_id", - "sandbox_policy", - "session_id", - "type" - ], - "title": "SessionConfiguredEventMsg", - "type": "object" - }, - { - "description": "Updated session metadata (e.g., thread name changes).", - "properties": { - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "thread_name_updated" - ], - "title": "ThreadNameUpdatedEventMsgType", - "type": "string" - } - }, - "required": [ - "thread_id", - "type" - ], - "title": "ThreadNameUpdatedEventMsg", - "type": "object" - }, - { - "description": "Incremental MCP startup progress updates.", - "properties": { - "server": { - "description": "Server name being started.", - "type": "string" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/McpStartupStatus" - } - ], - "description": "Current startup status." - }, - "type": { - "enum": [ - "mcp_startup_update" - ], - "title": "McpStartupUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "server", - "status", - "type" - ], - "title": "McpStartupUpdateEventMsg", - "type": "object" - }, - { - "description": "Aggregate MCP startup completion summary.", - "properties": { - "cancelled": { - "items": { - "type": "string" - }, - "type": "array" - }, - "failed": { - "items": { - "$ref": "#/definitions/McpStartupFailure" - }, - "type": "array" - }, - "ready": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "mcp_startup_complete" - ], - "title": "McpStartupCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "cancelled", - "failed", - "ready", - "type" - ], - "title": "McpStartupCompleteEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the McpToolCallEnd event.", - "type": "string" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "type": { - "enum": [ - "mcp_tool_call_begin" - ], - "title": "McpToolCallBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "invocation", - "type" - ], - "title": "McpToolCallBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier for the corresponding McpToolCallBegin that finished.", - "type": "string" - }, - "duration": { - "$ref": "#/definitions/Duration" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "result": { - "allOf": [ - { - "$ref": "#/definitions/Result_of_CallToolResult_or_String" - } - ], - "description": "Result of the tool call. Note this could be an error." - }, - "type": { - "enum": [ - "mcp_tool_call_end" - ], - "title": "McpToolCallEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "duration", - "invocation", - "result", - "type" - ], - "title": "McpToolCallEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_begin" - ], - "title": "WebSearchBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "WebSearchBeginEventMsg", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "call_id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_end" - ], - "title": "WebSearchEndEventMsgType", - "type": "string" - } - }, - "required": [ - "action", - "call_id", - "query", - "type" - ], - "title": "WebSearchEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the server is about to execute a command.", - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the ExecCommandEnd event.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_begin" - ], - "title": "ExecCommandBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "turn_id", - "type" - ], - "title": "ExecCommandBeginEventMsg", - "type": "object" - }, - { - "description": "Incremental chunk of output from a running command.", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "chunk": { - "description": "Raw bytes from the stream (may not be valid UTF-8).", - "type": "string" - }, - "stream": { - "allOf": [ - { - "$ref": "#/definitions/ExecOutputStream" - } - ], - "description": "Which stream produced this chunk." - }, - "type": { - "enum": [ - "exec_command_output_delta" - ], - "title": "ExecCommandOutputDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "chunk", - "stream", - "type" - ], - "title": "ExecCommandOutputDeltaEventMsg", - "type": "object" - }, - { - "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "process_id": { - "description": "Process id associated with the running command.", - "type": "string" - }, - "stdin": { - "description": "Stdin sent to the running session.", - "type": "string" - }, - "type": { - "enum": [ - "terminal_interaction" - ], - "title": "TerminalInteractionEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "process_id", - "stdin", - "type" - ], - "title": "TerminalInteractionEventMsg", - "type": "object" - }, - { - "properties": { - "aggregated_output": { - "default": "", - "description": "Captured aggregated output", - "type": "string" - }, - "call_id": { - "description": "Identifier for the ExecCommandBegin that finished.", - "type": "string" - }, - "command": { - "description": "The command that was executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "duration": { - "allOf": [ - { - "$ref": "#/definitions/Duration" - } - ], - "description": "The duration of the command execution." - }, - "exit_code": { - "description": "The command's exit code.", - "format": "int32", - "type": "integer" - }, - "formatted_output": { - "description": "Formatted output from the command, as seen by the model.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandStatus" - } - ], - "description": "Completion status for this command execution." - }, - "stderr": { - "description": "Captured stderr", - "type": "string" - }, - "stdout": { - "description": "Captured stdout", - "type": "string" - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_end" - ], - "title": "ExecCommandEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "duration", - "exit_code", - "formatted_output", - "parsed_cmd", - "status", - "stderr", - "stdout", - "turn_id", - "type" - ], - "title": "ExecCommandEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent attached a local image via the view_image tool.", - "properties": { - "call_id": { - "description": "Identifier for the originating tool call.", - "type": "string" - }, - "path": { - "description": "Local filesystem path provided to the tool.", - "type": "string" - }, - "type": { - "enum": [ - "view_image_tool_call" - ], - "title": "ViewImageToolCallEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "path", - "type" - ], - "title": "ViewImageToolCallEventMsg", - "type": "object" - }, - { - "properties": { - "approval_id": { - "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", - "type": [ - "string", - "null" - ] - }, - "call_id": { - "description": "Identifier for the associated command execution item.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory.", - "type": "string" - }, - "network_approval_context": { - "anyOf": [ - { - "$ref": "#/definitions/NetworkApprovalContext" - }, - { - "type": "null" - } - ], - "description": "Optional network context for a blocked request that can be approved." - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "proposed_execpolicy_amendment": { - "description": "Proposed execpolicy amendment that can be applied to allow future runs.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "reason": { - "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "exec_approval_request" - ], - "title": "ExecApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "type" - ], - "title": "ExecApprovalRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated tool call, if available.", - "type": "string" - }, - "questions": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestion" - }, - "type": "array" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "request_user_input" - ], - "title": "RequestUserInputEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "questions", - "type" - ], - "title": "RequestUserInputEventMsg", - "type": "object" - }, - { - "properties": { - "arguments": true, - "callId": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "turnId": { - "type": "string" - }, - "type": { - "enum": [ - "dynamic_tool_call_request" - ], - "title": "DynamicToolCallRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "arguments", - "callId", - "tool", - "turnId", - "type" - ], - "title": "DynamicToolCallRequestEventMsg", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "message": { - "type": "string" - }, - "server_name": { - "type": "string" - }, - "type": { - "enum": [ - "elicitation_request" - ], - "title": "ElicitationRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "message", - "server_name", - "type" - ], - "title": "ElicitationRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated patch apply call, if available.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "type": "object" - }, - "grant_root": { - "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", - "type": [ - "string", - "null" - ] - }, - "reason": { - "description": "Optional explanatory reason (e.g. request for extra write access).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", - "type": "string" - }, - "type": { - "enum": [ - "apply_patch_approval_request" - ], - "title": "ApplyPatchApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "changes", - "type" - ], - "title": "ApplyPatchApprovalRequestEventMsg", - "type": "object" - }, - { - "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", - "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", - "type": [ - "string", - "null" - ] - }, - "summary": { - "description": "Concise summary of what is deprecated.", - "type": "string" - }, - "type": { - "enum": [ - "deprecation_notice" - ], - "title": "DeprecationNoticeEventMsgType", - "type": "string" - } - }, - "required": [ - "summary", - "type" - ], - "title": "DeprecationNoticeEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "background_event" - ], - "title": "BackgroundEventEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "BackgroundEventEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "undo_started" - ], - "title": "UndoStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UndoStartedEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "success": { - "type": "boolean" - }, - "type": { - "enum": [ - "undo_completed" - ], - "title": "UndoCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "success", - "type" - ], - "title": "UndoCompletedEventMsg", - "type": "object" - }, - { - "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", - "properties": { - "additional_details": { - "default": null, - "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", - "type": [ - "string", - "null" - ] - }, - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "stream_error" - ], - "title": "StreamErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "StreamErrorEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", - "properties": { - "auto_approved": { - "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", - "type": "boolean" - }, - "call_id": { - "description": "Identifier so this can be paired with the PatchApplyEnd event.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "description": "The changes to be applied.", - "type": "object" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_begin" - ], - "title": "PatchApplyBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "auto_approved", - "call_id", - "changes", - "type" - ], - "title": "PatchApplyBeginEventMsg", - "type": "object" - }, - { - "description": "Notification that a patch application has finished.", - "properties": { - "call_id": { - "description": "Identifier for the PatchApplyBegin that finished.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "default": {}, - "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", - "type": "object" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/PatchApplyStatus" - } - ], - "description": "Completion status for this patch application." - }, - "stderr": { - "description": "Captured stderr (parser errors, IO failures, etc.).", - "type": "string" - }, - "stdout": { - "description": "Captured stdout (summary printed by apply_patch).", - "type": "string" - }, - "success": { - "description": "Whether the patch was applied successfully.", - "type": "boolean" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_end" - ], - "title": "PatchApplyEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "status", - "stderr", - "stdout", - "success", - "type" - ], - "title": "PatchApplyEndEventMsg", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "turn_diff" - ], - "title": "TurnDiffEventMsgType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "TurnDiffEventMsg", - "type": "object" - }, - { - "description": "Response to GetHistoryEntryRequest.", - "properties": { - "entry": { - "anyOf": [ - { - "$ref": "#/definitions/HistoryEntry" - }, - { - "type": "null" - } - ], - "description": "The entry at the requested offset, if available and parseable." - }, - "log_id": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "offset": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "get_history_entry_response" - ], - "title": "GetHistoryEntryResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "log_id", - "offset", - "type" - ], - "title": "GetHistoryEntryResponseEventMsg", - "type": "object" - }, - { - "description": "List of MCP tools available to the agent.", - "properties": { - "auth_statuses": { - "additionalProperties": { - "$ref": "#/definitions/McpAuthStatus" - }, - "description": "Authentication status for each configured MCP server.", - "type": "object" - }, - "resource_templates": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/ResourceTemplate" - }, - "type": "array" - }, - "description": "Known resource templates grouped by server name.", - "type": "object" - }, - "resources": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/Resource" - }, - "type": "array" - }, - "description": "Known resources grouped by server name.", - "type": "object" - }, - "tools": { - "additionalProperties": { - "$ref": "#/definitions/Tool" - }, - "description": "Fully qualified tool name -> tool definition.", - "type": "object" - }, - "type": { - "enum": [ - "mcp_list_tools_response" - ], - "title": "McpListToolsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "auth_statuses", - "resource_templates", - "resources", - "tools", - "type" - ], - "title": "McpListToolsResponseEventMsg", - "type": "object" - }, - { - "description": "List of custom prompts available to the agent.", - "properties": { - "custom_prompts": { - "items": { - "$ref": "#/definitions/CustomPrompt" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_custom_prompts_response" - ], - "title": "ListCustomPromptsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "custom_prompts", - "type" - ], - "title": "ListCustomPromptsResponseEventMsg", - "type": "object" - }, - { - "description": "List of skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/SkillsListEntry" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_skills_response" - ], - "title": "ListSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "List of remote skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_remote_skills_response" - ], - "title": "ListRemoteSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListRemoteSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "Remote skill downloaded to local cache.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "remote_skill_downloaded" - ], - "title": "RemoteSkillDownloadedEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "name", - "path", - "type" - ], - "title": "RemoteSkillDownloadedEventMsg", - "type": "object" - }, - { - "description": "Notification that skill data may have been updated and clients may want to reload.", - "properties": { - "type": { - "enum": [ - "skills_update_available" - ], - "title": "SkillsUpdateAvailableEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SkillsUpdateAvailableEventMsg", - "type": "object" - }, - { - "properties": { - "explanation": { - "default": null, - "description": "Arguments for the `update_plan` todo/checklist tool (not plan mode).", - "type": [ - "string", - "null" - ] - }, - "plan": { - "items": { - "$ref": "#/definitions/PlanItemArg" - }, - "type": "array" - }, - "type": { - "enum": [ - "plan_update" - ], - "title": "PlanUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "plan", - "type" - ], - "title": "PlanUpdateEventMsg", - "type": "object" - }, - { - "properties": { - "reason": { - "$ref": "#/definitions/TurnAbortReason" - }, - "turn_id": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "turn_aborted" - ], - "title": "TurnAbortedEventMsgType", - "type": "string" - } - }, - "required": [ - "reason", - "type" - ], - "title": "TurnAbortedEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is shutting down.", - "properties": { - "type": { - "enum": [ - "shutdown_complete" - ], - "title": "ShutdownCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ShutdownCompleteEventMsg", - "type": "object" - }, - { - "description": "Entered review mode.", - "properties": { - "target": { - "$ref": "#/definitions/ReviewTarget" - }, - "type": { - "enum": [ - "entered_review_mode" - ], - "title": "EnteredReviewModeEventMsgType", - "type": "string" - }, - "user_facing_hint": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "target", - "type" - ], - "title": "EnteredReviewModeEventMsg", - "type": "object" - }, - { - "description": "Exited review mode with an optional final result to apply.", - "properties": { - "review_output": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewOutputEvent" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "exited_review_mode" - ], - "title": "ExitedReviewModeEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExitedReviewModeEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/ResponseItem" - }, - "type": { - "enum": [ - "raw_response_item" - ], - "title": "RawResponseItemEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "type" - ], - "title": "RawResponseItemEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_started" - ], - "title": "ItemStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemStartedEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_completed" - ], - "title": "ItemCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemCompletedEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_content_delta" - ], - "title": "AgentMessageContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "AgentMessageContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "plan_delta" - ], - "title": "PlanDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "PlanDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_content_delta" - ], - "title": "ReasoningContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "content_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_raw_content_delta" - ], - "title": "ReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_spawn_begin" - ], - "title": "CollabAgentSpawnBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "type" - ], - "title": "CollabAgentSpawnBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "new_agent_nickname": { - "description": "Optional nickname assigned to the new agent.", - "type": [ - "string", - "null" - ] - }, - "new_agent_role": { - "description": "Optional role assigned to the new agent.", - "type": [ - "string", - "null" - ] - }, - "new_thread_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ], - "description": "Thread ID of the newly spawned agent, if it was created." - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the new agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_spawn_end" - ], - "title": "CollabAgentSpawnEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentSpawnEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_interaction_begin" - ], - "title": "CollabAgentInteractionBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabAgentInteractionBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_interaction_end" - ], - "title": "CollabAgentInteractionEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentInteractionEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting begin.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "receiver_agents": { - "description": "Optional nicknames/roles for receivers.", - "items": { - "$ref": "#/definitions/CollabAgentRef" - }, - "type": "array" - }, - "receiver_thread_ids": { - "description": "Thread ID of the receivers.", - "items": { - "$ref": "#/definitions/ThreadId" - }, - "type": "array" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_waiting_begin" - ], - "title": "CollabWaitingBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_ids", - "sender_thread_id", - "type" - ], - "title": "CollabWaitingBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting end.", - "properties": { - "agent_statuses": { - "description": "Optional receiver metadata paired with final statuses.", - "items": { - "$ref": "#/definitions/CollabAgentStatusEntry" - }, - "type": "array" - }, - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "statuses": { - "additionalProperties": { - "$ref": "#/definitions/AgentStatus" - }, - "description": "Last known status of the receiver agents reported to the sender agent.", - "type": "object" - }, - "type": { - "enum": [ - "collab_waiting_end" - ], - "title": "CollabWaitingEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "sender_thread_id", - "statuses", - "type" - ], - "title": "CollabWaitingEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_close_begin" - ], - "title": "CollabCloseBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabCloseBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent before the close." - }, - "type": { - "enum": [ - "collab_close_end" - ], - "title": "CollabCloseEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabCloseEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_resume_begin" - ], - "title": "CollabResumeBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabResumeBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent after resume." - }, - "type": { - "enum": [ - "collab_resume_end" - ], - "title": "CollabResumeEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabResumeEndEventMsg", - "type": "object" - } - ] - }, - "ExecCommandSource": { - "enum": [ - "agent", - "user_shell", - "unified_exec_startup", - "unified_exec_interaction" - ], - "type": "string" - }, - "ExecCommandStatus": { - "enum": [ - "completed", - "failed", - "declined" - ], - "type": "string" - }, - "ExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" - }, - "FileChange": { - "oneOf": [ - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "add" - ], - "title": "AddFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "AddFileChange", - "type": "object" - }, - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "delete" - ], - "title": "DeleteFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "DeleteFileChange", - "type": "object" - }, - { - "properties": { - "move_path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "update" - ], - "title": "UpdateFileChangeType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "UpdateFileChange", - "type": "object" - } - ] - }, - "FunctionCallOutputBody": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" - }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, - "HistoryEntry": { - "properties": { - "conversation_id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "ts": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "conversation_id", - "text", - "ts" - ], - "type": "object" - }, - "LocalShellAction": { - "oneOf": [ - { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "exec" - ], - "title": "ExecLocalShellActionType", - "type": "string" - }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "command", - "type" - ], - "title": "ExecLocalShellAction", - "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "McpAuthStatus": { - "enum": [ - "unsupported", - "not_logged_in", - "bearer_token", - "o_auth" - ], - "type": "string" - }, - "McpInvocation": { - "properties": { - "arguments": { - "description": "Arguments to the tool call." - }, - "server": { - "description": "Name of the MCP server as defined in the config.", - "type": "string" - }, - "tool": { - "description": "Name of the tool as given by the MCP server.", - "type": "string" - } - }, - "required": [ - "server", - "tool" - ], - "type": "object" - }, - "McpStartupFailure": { - "properties": { - "error": { - "type": "string" - }, - "server": { - "type": "string" - } - }, - "required": [ - "error", - "server" - ], - "type": "object" - }, - "McpStartupStatus": { - "oneOf": [ - { - "properties": { - "state": { - "enum": [ - "starting" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus", - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "ready" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus2", - "type": "object" - }, - { - "properties": { - "error": { - "type": "string" - }, - "state": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "error", - "state" - ], - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "cancelled" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus3", - "type": "object" - } - ] - }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], - "type": "string" - }, - { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" - ], - "type": "string" - }, - "ModelRerouteReason": { - "enum": [ - "high_risk_cyber_activity" - ], - "type": "string" - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "NetworkApprovalContext": { - "properties": { - "host": { - "type": "string" - }, - "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" - } - }, - "required": [ - "host", - "protocol" - ], - "type": "object" - }, - "NetworkApprovalProtocol": { - "enum": [ - "http", - "https", - "socks5_tcp", - "socks5_udp" - ], - "type": "string" - }, - "ParsedCommand": { - "oneOf": [ - { - "properties": { - "cmd": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", - "type": "string" - }, - "type": { - "enum": [ - "read" - ], - "title": "ReadParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "name", - "path", - "type" - ], - "title": "ReadParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "list_files" - ], - "title": "ListFilesParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ListFilesParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "SearchParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "unknown" - ], - "title": "UnknownParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "UnknownParsedCommand", - "type": "object" - } - ] - }, - "PatchApplyStatus": { - "enum": [ - "completed", - "failed", - "declined" - ], - "type": "string" - }, - "PlanItemArg": { - "additionalProperties": false, - "properties": { - "status": { - "$ref": "#/definitions/StepStatus" - }, - "step": { - "type": "string" - } - }, - "required": [ - "status", - "step" - ], - "type": "object" - }, - "PlanType": { - "enum": [ - "free", - "go", - "plus", - "pro", - "team", - "business", - "enterprise", - "edu", - "unknown" - ], - "type": "string" - }, - "RateLimitSnapshot": { - "properties": { - "credits": { - "anyOf": [ - { - "$ref": "#/definitions/CreditsSnapshot" - }, - { - "type": "null" - } - ] - }, - "limit_id": { - "type": [ - "string", - "null" - ] - }, - "limit_name": { - "type": [ - "string", - "null" - ] - }, - "plan_type": { - "anyOf": [ - { - "$ref": "#/definitions/PlanType" - }, - { - "type": "null" - } - ] - }, - "primary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - }, - "secondary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RateLimitWindow": { - "properties": { - "resets_at": { - "description": "Unix timestamp (seconds since epoch) when the window resets.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "used_percent": { - "description": "Percentage (0-100) of the window that has been consumed.", - "format": "double", - "type": "number" - }, - "window_minutes": { - "description": "Rolling window duration, in minutes.", - "format": "int64", - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "used_percent" - ], - "type": "object" - }, - "ReadOnlyAccess": { - "description": "Determines how read-only file access is granted inside a restricted sandbox.", - "oneOf": [ - { - "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", - "properties": { - "include_platform_defaults": { - "default": true, - "description": "Include built-in platform read roots required for basic process execution.", - "type": "boolean" - }, - "readable_roots": { - "description": "Additional absolute roots that should be readable.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "description": "Allow unrestricted file reads.", - "properties": { - "type": { - "enum": [ - "full-access" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, - "RealtimeAudioFrame": { - "properties": { - "data": { - "type": "string" - }, - "num_channels": { - "format": "uint16", - "minimum": 0.0, - "type": "integer" - }, - "sample_rate": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "samples_per_channel": { - "format": "uint32", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "data", - "num_channels", - "sample_rate" - ], - "type": "object" - }, - "RealtimeEvent": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "SessionCreated": { - "properties": { - "session_id": { - "type": "string" - } - }, - "required": [ - "session_id" - ], - "type": "object" - } - }, - "required": [ - "SessionCreated" - ], - "title": "SessionCreatedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "SessionUpdated": { - "properties": { - "backend_prompt": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "SessionUpdated" - ], - "title": "SessionUpdatedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "AudioOut": { - "$ref": "#/definitions/RealtimeAudioFrame" - } - }, - "required": [ - "AudioOut" - ], - "title": "AudioOutRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "ConversationItemAdded": true - }, - "required": [ - "ConversationItemAdded" - ], - "title": "ConversationItemAddedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "Error": { - "type": "string" - } - }, - "required": [ - "Error" - ], - "title": "ErrorRealtimeEvent", - "type": "object" - } - ] - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, - "RequestId": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "int64", - "type": "integer" - } - ], - "description": "ID of a request, which can be either a string or an integer." - }, - "RequestUserInputQuestion": { - "properties": { - "header": { - "type": "string" - }, - "id": { - "type": "string" - }, - "isOther": { - "default": false, - "type": "boolean" - }, - "isSecret": { - "default": false, - "type": "boolean" - }, - "options": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestionOption" - }, - "type": [ - "array", - "null" - ] - }, - "question": { - "type": "string" - } - }, - "required": [ - "header", - "id", - "question" - ], - "type": "object" - }, - "RequestUserInputQuestionOption": { - "properties": { - "description": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "description", - "label" - ], - "type": "object" - }, - "Resource": { - "description": "A known resource that the server is capable of reading.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "size": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "name", - "uri" - ], - "type": "object" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server.", - "properties": { - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "type": "string" - } - }, - "required": [ - "name", - "uriTemplate" - ], - "type": "object" - }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" - }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "type": "string" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction" - ], - "title": "CompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionResponseItem", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" - } - ] - }, - "Result_of_CallToolResult_or_String": { - "oneOf": [ - { - "properties": { - "Ok": { - "$ref": "#/definitions/CallToolResult" - } - }, - "required": [ - "Ok" - ], - "title": "OkResult_of_CallToolResult_or_String", - "type": "object" - }, - { - "properties": { - "Err": { - "type": "string" - } - }, - "required": [ - "Err" - ], - "title": "ErrResult_of_CallToolResult_or_String", - "type": "object" - } - ] - }, - "ReviewCodeLocation": { - "description": "Location of the code related to a review finding.", - "properties": { - "absolute_file_path": { - "type": "string" - }, - "line_range": { - "$ref": "#/definitions/ReviewLineRange" - } - }, - "required": [ - "absolute_file_path", - "line_range" - ], - "type": "object" - }, - "ReviewFinding": { - "description": "A single review finding describing an observed issue or recommendation.", - "properties": { - "body": { - "type": "string" - }, - "code_location": { - "$ref": "#/definitions/ReviewCodeLocation" - }, - "confidence_score": { - "format": "float", - "type": "number" - }, - "priority": { - "format": "int32", - "type": "integer" - }, - "title": { - "type": "string" - } - }, - "required": [ - "body", - "code_location", - "confidence_score", - "priority", - "title" - ], - "type": "object" - }, - "ReviewLineRange": { - "description": "Inclusive line range in a file associated with the finding.", - "properties": { - "end": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "ReviewOutputEvent": { - "description": "Structured review result produced by a child review session.", - "properties": { - "findings": { - "items": { - "$ref": "#/definitions/ReviewFinding" - }, - "type": "array" - }, - "overall_confidence_score": { - "format": "float", - "type": "number" - }, - "overall_correctness": { - "type": "string" - }, - "overall_explanation": { - "type": "string" - } - }, - "required": [ - "findings", - "overall_confidence_score", - "overall_correctness", - "overall_explanation" - ], - "type": "object" - }, - "ReviewTarget": { - "oneOf": [ - { - "description": "Review the working tree: staged, unstaged, and untracked files.", - "properties": { - "type": { - "enum": [ - "uncommittedChanges" - ], - "title": "UncommittedChangesReviewTargetType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UncommittedChangesReviewTarget", - "type": "object" - }, - { - "description": "Review changes between the current branch and the given base branch.", - "properties": { - "branch": { - "type": "string" - }, - "type": { - "enum": [ - "baseBranch" - ], - "title": "BaseBranchReviewTargetType", - "type": "string" - } - }, - "required": [ - "branch", - "type" - ], - "title": "BaseBranchReviewTarget", - "type": "object" - }, - { - "description": "Review the changes introduced by a specific commit.", - "properties": { - "sha": { - "type": "string" - }, - "title": { - "description": "Optional human-readable label (e.g., commit subject) for UIs.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "commit" - ], - "title": "CommitReviewTargetType", - "type": "string" - } - }, - "required": [ - "sha", - "type" - ], - "title": "CommitReviewTarget", - "type": "object" - }, - { - "description": "Arbitrary instructions provided by the user.", - "properties": { - "instructions": { - "type": "string" - }, - "type": { - "enum": [ - "custom" - ], - "title": "CustomReviewTargetType", - "type": "string" - } - }, - "required": [ - "instructions", - "type" - ], - "title": "CustomReviewTarget", - "type": "object" - } - ] - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access configuration.", - "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "read_only_access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - }, - "SessionNetworkProxyRuntime": { - "properties": { - "admin_addr": { - "type": "string" - }, - "http_addr": { - "type": "string" - }, - "socks_addr": { - "type": "string" - } - }, - "required": [ - "admin_addr", - "http_addr", - "socks_addr" - ], - "type": "object" - }, - "SkillDependencies": { - "properties": { - "tools": { - "items": { - "$ref": "#/definitions/SkillToolDependency" - }, - "type": "array" - } - }, - "required": [ - "tools" - ], - "type": "object" - }, - "SkillErrorInfo": { - "properties": { - "message": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "message", - "path" - ], - "type": "object" - }, - "SkillInterface": { - "properties": { - "brand_color": { - "type": [ - "string", - "null" - ] - }, - "default_prompt": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "icon_large": { - "type": [ - "string", - "null" - ] - }, - "icon_small": { - "type": [ - "string", - "null" - ] - }, - "short_description": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "SkillMetadata": { - "properties": { - "dependencies": { - "anyOf": [ - { - "$ref": "#/definitions/SkillDependencies" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "interface": { - "anyOf": [ - { - "$ref": "#/definitions/SkillInterface" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "scope": { - "$ref": "#/definitions/SkillScope" - }, - "short_description": { - "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "description", - "enabled", - "name", - "path", - "scope" - ], - "type": "object" - }, - "SkillScope": { - "enum": [ - "user", - "repo", - "system", - "admin" - ], - "type": "string" - }, - "SkillToolDependency": { - "properties": { - "command": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "transport": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "value" - ], - "type": "object" - }, - "SkillsListEntry": { - "properties": { - "cwd": { - "type": "string" - }, - "errors": { - "items": { - "$ref": "#/definitions/SkillErrorInfo" - }, - "type": "array" - }, - "skills": { - "items": { - "$ref": "#/definitions/SkillMetadata" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "errors", - "skills" - ], - "type": "object" - }, - "StepStatus": { - "enum": [ - "pending", - "in_progress", - "completed" - ], - "type": "string" - }, - "TextElement": { - "properties": { - "byte_range": { - "allOf": [ - { - "$ref": "#/definitions/ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byte_range" - ], - "type": "object" - }, - "ThreadId": { - "type": "string" - }, - "TokenUsage": { - "properties": { - "cached_input_tokens": { - "format": "int64", - "type": "integer" - }, - "input_tokens": { - "format": "int64", - "type": "integer" - }, - "output_tokens": { - "format": "int64", - "type": "integer" - }, - "reasoning_output_tokens": { - "format": "int64", - "type": "integer" - }, - "total_tokens": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "cached_input_tokens", - "input_tokens", - "output_tokens", - "reasoning_output_tokens", - "total_tokens" - ], - "type": "object" - }, - "TokenUsageInfo": { - "properties": { - "last_token_usage": { - "$ref": "#/definitions/TokenUsage" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "total_token_usage": { - "$ref": "#/definitions/TokenUsage" - } - }, - "required": [ - "last_token_usage", - "total_token_usage" - ], - "type": "object" - }, - "Tool": { - "description": "Definition for a tool the client can call.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "inputSchema": true, - "name": { - "type": "string" - }, - "outputSchema": true, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "inputSchema", - "name" - ], - "type": "object" - }, - "TurnAbortReason": { - "enum": [ - "interrupted", - "replaced", - "review_ended" - ], - "type": "string" - }, - "TurnItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/UserInput" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "type": { - "enum": [ - "UserMessage" - ], - "title": "UserMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "UserMessageTurnItem", - "type": "object" - }, - { - "description": "Assistant-authored message payload used in turn-item streams.\n\n`phase` is optional because not all providers/models emit it. Consumers should use it when present, but retain legacy completion semantics when it is `None`.", - "properties": { - "content": { - "items": { - "$ref": "#/definitions/AgentMessageContent" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." - }, - "type": { - "enum": [ - "AgentMessage" - ], - "title": "AgentMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "AgentMessageTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Plan" - ], - "title": "PlanTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "text", - "type" - ], - "title": "PlanTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "raw_content": { - "default": [], - "items": { - "type": "string" - }, - "type": "array" - }, - "summary_text": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "Reasoning" - ], - "title": "ReasoningTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary_text", - "type" - ], - "title": "ReasoningTurnItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "WebSearch" - ], - "title": "WebSearchTurnItemType", - "type": "string" - } - }, - "required": [ - "action", - "id", - "query", - "type" - ], - "title": "WebSearchTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "type": { - "enum": [ - "ContextCompaction" - ], - "title": "ContextCompactionTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ContextCompactionTurnItem", - "type": "object" - } - ] - }, - "UserInput": { - "description": "User input", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` that should be treated as special elements. These are byte ranges into the UTF-8 `text` buffer and are used to render or persist rich input markers (e.g., image placeholders) across history and resume without mutating the literal text.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextUserInputType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextUserInput", - "type": "object" - }, - { - "description": "Pre‑encoded data: URI image.", - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageUserInputType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "ImageUserInput", - "type": "object" - }, - { - "description": "Local image path provided by the user. This will be converted to an `Image` variant (base64 data URL) during request serialization.", - "properties": { - "path": { - "type": "string" - }, - "type": { - "enum": [ - "local_image" - ], - "title": "LocalImageUserInputType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "LocalImageUserInput", - "type": "object" - }, - { - "description": "Skill selected by the user (name + path to SKILL.md).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "skill" - ], - "title": "SkillUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "SkillUserInput", - "type": "object" - }, - { - "description": "Explicit mention selected by the user (name + app://connector id).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "mention" - ], - "title": "MentionUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "MentionUserInput", - "type": "object" - } - ] - }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "model", - "rolloutPath" - ], - "title": "ResumeConversationResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/SendUserMessageParams.json b/codex-rs/app-server-protocol/schema/json/v1/SendUserMessageParams.json deleted file mode 100644 index 1f53acc006..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/SendUserMessageParams.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "InputItem": { - "oneOf": [ - { - "properties": { - "data": { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/V1TextElement" - }, - "type": "array" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "TextInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "image_url": { - "type": "string" - } - }, - "required": [ - "image_url" - ], - "type": "object" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "ImageInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "path": { - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "type": { - "enum": [ - "localImage" - ], - "title": "LocalImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "LocalImageInputItem", - "type": "object" - } - ] - }, - "ThreadId": { - "type": "string" - }, - "V1ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "V1TextElement": { - "properties": { - "byteRange": { - "allOf": [ - { - "$ref": "#/definitions/V1ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byteRange" - ], - "type": "object" - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "items": { - "items": { - "$ref": "#/definitions/InputItem" - }, - "type": "array" - } - }, - "required": [ - "conversationId", - "items" - ], - "title": "SendUserMessageParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/SendUserMessageResponse.json b/codex-rs/app-server-protocol/schema/json/v1/SendUserMessageResponse.json deleted file mode 100644 index df3df37c83..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/SendUserMessageResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SendUserMessageResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json b/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json deleted file mode 100644 index a213f6077e..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json +++ /dev/null @@ -1,482 +0,0 @@ -{ - "$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" - }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "InputItem": { - "oneOf": [ - { - "properties": { - "data": { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/V1TextElement" - }, - "type": "array" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "TextInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "image_url": { - "type": "string" - } - }, - "required": [ - "image_url" - ], - "type": "object" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "ImageInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "path": { - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "type": { - "enum": [ - "localImage" - ], - "title": "LocalImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "LocalImageInputItem", - "type": "object" - } - ] - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "ReadOnlyAccess": { - "description": "Determines how read-only file access is granted inside a restricted sandbox.", - "oneOf": [ - { - "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", - "properties": { - "include_platform_defaults": { - "default": true, - "description": "Include built-in platform read roots required for basic process execution.", - "type": "boolean" - }, - "readable_roots": { - "description": "Additional absolute roots that should be readable.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "description": "Allow unrestricted file reads.", - "properties": { - "type": { - "enum": [ - "full-access" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningSummary": { - "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", - "oneOf": [ - { - "enum": [ - "auto", - "concise", - "detailed" - ], - "type": "string" - }, - { - "description": "Option to disable reasoning summaries.", - "enum": [ - "none" - ], - "type": "string" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access configuration.", - "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "read_only_access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - }, - "ThreadId": { - "type": "string" - }, - "V1ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "V1TextElement": { - "properties": { - "byteRange": { - "allOf": [ - { - "$ref": "#/definitions/V1ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byteRange" - ], - "type": "object" - } - }, - "properties": { - "approvalPolicy": { - "$ref": "#/definitions/AskForApproval" - }, - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "cwd": { - "type": "string" - }, - "effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "items": { - "items": { - "$ref": "#/definitions/InputItem" - }, - "type": "array" - }, - "model": { - "type": "string" - }, - "outputSchema": { - "description": "Optional JSON Schema used to constrain the final assistant message for this turn." - }, - "sandboxPolicy": { - "$ref": "#/definitions/SandboxPolicy" - }, - "summary": { - "$ref": "#/definitions/ReasoningSummary" - } - }, - "required": [ - "approvalPolicy", - "conversationId", - "cwd", - "items", - "model", - "sandboxPolicy", - "summary" - ], - "title": "SendUserTurnParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnResponse.json b/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnResponse.json deleted file mode 100644 index 5dc36098ff..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SendUserTurnResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json deleted file mode 100644 index a3010337bb..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ /dev/null @@ -1,5798 +0,0 @@ -{ - "$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" - }, - "AgentMessageContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Text" - ], - "title": "TextAgentMessageContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextAgentMessageContent", - "type": "object" - } - ] - }, - "AgentStatus": { - "description": "Agent lifecycle status, derived from emitted events.", - "oneOf": [ - { - "description": "Agent is waiting for initialization.", - "enum": [ - "pending_init" - ], - "type": "string" - }, - { - "description": "Agent is currently running.", - "enum": [ - "running" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Agent is done. Contains the final assistant message.", - "properties": { - "completed": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "completed" - ], - "title": "CompletedAgentStatus", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Agent encountered an error.", - "properties": { - "errored": { - "type": "string" - } - }, - "required": [ - "errored" - ], - "title": "ErroredAgentStatus", - "type": "object" - }, - { - "description": "Agent has been shutdown.", - "enum": [ - "shutdown" - ], - "type": "string" - }, - { - "description": "Agent is not found.", - "enum": [ - "not_found" - ], - "type": "string" - } - ] - }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "CallToolResult": { - "description": "The server's response to a tool call.", - "properties": { - "_meta": true, - "content": { - "items": true, - "type": "array" - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true - }, - "required": [ - "content" - ], - "type": "object" - }, - "CodexErrorInfo": { - "description": "Codex errors that we expose to clients.", - "oneOf": [ - { - "enum": [ - "context_window_exceeded", - "usage_limit_exceeded", - "server_overloaded", - "internal_server_error", - "unauthorized", - "bad_request", - "sandbox_error", - "thread_rollback_failed", - "other" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "http_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "http_connection_failed" - ], - "title": "HttpConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Failed to connect to the response SSE stream.", - "properties": { - "response_stream_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_connection_failed" - ], - "title": "ResponseStreamConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "The response SSE stream disconnected in the middle of a turnbefore completion.", - "properties": { - "response_stream_disconnected": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_disconnected" - ], - "title": "ResponseStreamDisconnectedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Reached the retry limit for responses.", - "properties": { - "response_too_many_failed_attempts": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_too_many_failed_attempts" - ], - "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", - "type": "object" - } - ] - }, - "CollabAgentRef": { - "properties": { - "agent_nickname": { - "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "agent_role": { - "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver/new agent." - } - }, - "required": [ - "thread_id" - ], - "type": "object" - }, - "CollabAgentStatusEntry": { - "properties": { - "agent_nickname": { - "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "agent_role": { - "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", - "type": [ - "string", - "null" - ] - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the agent." - }, - "thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver/new agent." - } - }, - "required": [ - "status", - "thread_id" - ], - "type": "object" - }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, - "CreditsSnapshot": { - "properties": { - "balance": { - "type": [ - "string", - "null" - ] - }, - "has_credits": { - "type": "boolean" - }, - "unlimited": { - "type": "boolean" - } - }, - "required": [ - "has_credits", - "unlimited" - ], - "type": "object" - }, - "CustomPrompt": { - "properties": { - "argument_hint": { - "type": [ - "string", - "null" - ] - }, - "content": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "content", - "name", - "path" - ], - "type": "object" - }, - "Duration": { - "properties": { - "nanos": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "secs": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "nanos", - "secs" - ], - "type": "object" - }, - "EventMsg": { - "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", - "oneOf": [ - { - "description": "Error while executing a submission", - "properties": { - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "error" - ], - "title": "ErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "ErrorEventMsg", - "type": "object" - }, - { - "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "warning" - ], - "title": "WarningEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "WarningEventMsg", - "type": "object" - }, - { - "description": "Realtime conversation lifecycle start event.", - "properties": { - "session_id": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "realtime_conversation_started" - ], - "title": "RealtimeConversationStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RealtimeConversationStartedEventMsg", - "type": "object" - }, - { - "description": "Realtime conversation streaming payload event.", - "properties": { - "payload": { - "$ref": "#/definitions/RealtimeEvent" - }, - "type": { - "enum": [ - "realtime_conversation_realtime" - ], - "title": "RealtimeConversationRealtimeEventMsgType", - "type": "string" - } - }, - "required": [ - "payload", - "type" - ], - "title": "RealtimeConversationRealtimeEventMsg", - "type": "object" - }, - { - "description": "Realtime conversation lifecycle close event.", - "properties": { - "reason": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "realtime_conversation_closed" - ], - "title": "RealtimeConversationClosedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RealtimeConversationClosedEventMsg", - "type": "object" - }, - { - "description": "Model routing changed from the requested model to a different model.", - "properties": { - "from_model": { - "type": "string" - }, - "reason": { - "$ref": "#/definitions/ModelRerouteReason" - }, - "to_model": { - "type": "string" - }, - "type": { - "enum": [ - "model_reroute" - ], - "title": "ModelRerouteEventMsgType", - "type": "string" - } - }, - "required": [ - "from_model", - "reason", - "to_model", - "type" - ], - "title": "ModelRerouteEventMsg", - "type": "object" - }, - { - "description": "Conversation history was compacted (either automatically or manually).", - "properties": { - "type": { - "enum": [ - "context_compacted" - ], - "title": "ContextCompactedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactedEventMsg", - "type": "object" - }, - { - "description": "Conversation history was rolled back by dropping the last N user turns.", - "properties": { - "num_turns": { - "description": "Number of user turns that were removed from context.", - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "thread_rolled_back" - ], - "title": "ThreadRolledBackEventMsgType", - "type": "string" - } - }, - "required": [ - "num_turns", - "type" - ], - "title": "ThreadRolledBackEventMsg", - "type": "object" - }, - { - "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", - "properties": { - "collaboration_mode_kind": { - "allOf": [ - { - "$ref": "#/definitions/ModeKind" - } - ], - "default": "default" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "task_started" - ], - "title": "TaskStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "turn_id", - "type" - ], - "title": "TaskStartedEventMsg", - "type": "object" - }, - { - "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", - "properties": { - "last_agent_message": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "task_complete" - ], - "title": "TaskCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "turn_id", - "type" - ], - "title": "TaskCompleteEventMsg", - "type": "object" - }, - { - "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", - "properties": { - "info": { - "anyOf": [ - { - "$ref": "#/definitions/TokenUsageInfo" - }, - { - "type": "null" - } - ] - }, - "rate_limits": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitSnapshot" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "token_count" - ], - "title": "TokenCountEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TokenCountEventMsg", - "type": "object" - }, - { - "description": "Agent text output message", - "properties": { - "message": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "default": null - }, - "type": { - "enum": [ - "agent_message" - ], - "title": "AgentMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "AgentMessageEventMsg", - "type": "object" - }, - { - "description": "User/system input message (what was sent to the model)", - "properties": { - "images": { - "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "local_images": { - "default": [], - "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", - "items": { - "type": "string" - }, - "type": "array" - }, - "message": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `message` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "user_message" - ], - "title": "UserMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "UserMessageEventMsg", - "type": "object" - }, - { - "description": "Agent text output delta message", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_delta" - ], - "title": "AgentMessageDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentMessageDeltaEventMsg", - "type": "object" - }, - { - "description": "Reasoning event from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning" - ], - "title": "AgentReasoningEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_delta" - ], - "title": "AgentReasoningDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningDeltaEventMsg", - "type": "object" - }, - { - "description": "Raw chain-of-thought from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content" - ], - "title": "AgentReasoningRawContentEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningRawContentEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning content delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content_delta" - ], - "title": "AgentReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", - "properties": { - "item_id": { - "default": "", - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "type": { - "enum": [ - "agent_reasoning_section_break" - ], - "title": "AgentReasoningSectionBreakEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AgentReasoningSectionBreakEventMsg", - "type": "object" - }, - { - "description": "Ack the client's configure message.", - "properties": { - "approval_policy": { - "allOf": [ - { - "$ref": "#/definitions/AskForApproval" - } - ], - "description": "When to escalate for approval for execution" - }, - "cwd": { - "description": "Working directory that should be treated as the *root* of the session.", - "type": "string" - }, - "forked_from_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history_entry_count": { - "description": "Current number of entries in the history log.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "history_log_id": { - "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initial_messages": { - "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "description": "Tell the client what model is being queried.", - "type": "string" - }, - "model_provider_id": { - "type": "string" - }, - "network_proxy": { - "anyOf": [ - { - "$ref": "#/definitions/SessionNetworkProxyRuntime" - }, - { - "type": "null" - } - ], - "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." - }, - "reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ], - "description": "The effort the model is putting into reasoning about the user's request." - }, - "rollout_path": { - "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", - "type": [ - "string", - "null" - ] - }, - "sandbox_policy": { - "allOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - } - ], - "description": "How to sandbox commands executed in the system" - }, - "session_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "description": "Optional user-facing thread name (may be unset).", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "session_configured" - ], - "title": "SessionConfiguredEventMsgType", - "type": "string" - } - }, - "required": [ - "approval_policy", - "cwd", - "history_entry_count", - "history_log_id", - "model", - "model_provider_id", - "sandbox_policy", - "session_id", - "type" - ], - "title": "SessionConfiguredEventMsg", - "type": "object" - }, - { - "description": "Updated session metadata (e.g., thread name changes).", - "properties": { - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "thread_name_updated" - ], - "title": "ThreadNameUpdatedEventMsgType", - "type": "string" - } - }, - "required": [ - "thread_id", - "type" - ], - "title": "ThreadNameUpdatedEventMsg", - "type": "object" - }, - { - "description": "Incremental MCP startup progress updates.", - "properties": { - "server": { - "description": "Server name being started.", - "type": "string" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/McpStartupStatus" - } - ], - "description": "Current startup status." - }, - "type": { - "enum": [ - "mcp_startup_update" - ], - "title": "McpStartupUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "server", - "status", - "type" - ], - "title": "McpStartupUpdateEventMsg", - "type": "object" - }, - { - "description": "Aggregate MCP startup completion summary.", - "properties": { - "cancelled": { - "items": { - "type": "string" - }, - "type": "array" - }, - "failed": { - "items": { - "$ref": "#/definitions/McpStartupFailure" - }, - "type": "array" - }, - "ready": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "mcp_startup_complete" - ], - "title": "McpStartupCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "cancelled", - "failed", - "ready", - "type" - ], - "title": "McpStartupCompleteEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the McpToolCallEnd event.", - "type": "string" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "type": { - "enum": [ - "mcp_tool_call_begin" - ], - "title": "McpToolCallBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "invocation", - "type" - ], - "title": "McpToolCallBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier for the corresponding McpToolCallBegin that finished.", - "type": "string" - }, - "duration": { - "$ref": "#/definitions/Duration" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "result": { - "allOf": [ - { - "$ref": "#/definitions/Result_of_CallToolResult_or_String" - } - ], - "description": "Result of the tool call. Note this could be an error." - }, - "type": { - "enum": [ - "mcp_tool_call_end" - ], - "title": "McpToolCallEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "duration", - "invocation", - "result", - "type" - ], - "title": "McpToolCallEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_begin" - ], - "title": "WebSearchBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "WebSearchBeginEventMsg", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "call_id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_end" - ], - "title": "WebSearchEndEventMsgType", - "type": "string" - } - }, - "required": [ - "action", - "call_id", - "query", - "type" - ], - "title": "WebSearchEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the server is about to execute a command.", - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the ExecCommandEnd event.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_begin" - ], - "title": "ExecCommandBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "turn_id", - "type" - ], - "title": "ExecCommandBeginEventMsg", - "type": "object" - }, - { - "description": "Incremental chunk of output from a running command.", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "chunk": { - "description": "Raw bytes from the stream (may not be valid UTF-8).", - "type": "string" - }, - "stream": { - "allOf": [ - { - "$ref": "#/definitions/ExecOutputStream" - } - ], - "description": "Which stream produced this chunk." - }, - "type": { - "enum": [ - "exec_command_output_delta" - ], - "title": "ExecCommandOutputDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "chunk", - "stream", - "type" - ], - "title": "ExecCommandOutputDeltaEventMsg", - "type": "object" - }, - { - "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "process_id": { - "description": "Process id associated with the running command.", - "type": "string" - }, - "stdin": { - "description": "Stdin sent to the running session.", - "type": "string" - }, - "type": { - "enum": [ - "terminal_interaction" - ], - "title": "TerminalInteractionEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "process_id", - "stdin", - "type" - ], - "title": "TerminalInteractionEventMsg", - "type": "object" - }, - { - "properties": { - "aggregated_output": { - "default": "", - "description": "Captured aggregated output", - "type": "string" - }, - "call_id": { - "description": "Identifier for the ExecCommandBegin that finished.", - "type": "string" - }, - "command": { - "description": "The command that was executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "duration": { - "allOf": [ - { - "$ref": "#/definitions/Duration" - } - ], - "description": "The duration of the command execution." - }, - "exit_code": { - "description": "The command's exit code.", - "format": "int32", - "type": "integer" - }, - "formatted_output": { - "description": "Formatted output from the command, as seen by the model.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandStatus" - } - ], - "description": "Completion status for this command execution." - }, - "stderr": { - "description": "Captured stderr", - "type": "string" - }, - "stdout": { - "description": "Captured stdout", - "type": "string" - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_end" - ], - "title": "ExecCommandEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "duration", - "exit_code", - "formatted_output", - "parsed_cmd", - "status", - "stderr", - "stdout", - "turn_id", - "type" - ], - "title": "ExecCommandEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent attached a local image via the view_image tool.", - "properties": { - "call_id": { - "description": "Identifier for the originating tool call.", - "type": "string" - }, - "path": { - "description": "Local filesystem path provided to the tool.", - "type": "string" - }, - "type": { - "enum": [ - "view_image_tool_call" - ], - "title": "ViewImageToolCallEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "path", - "type" - ], - "title": "ViewImageToolCallEventMsg", - "type": "object" - }, - { - "properties": { - "approval_id": { - "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", - "type": [ - "string", - "null" - ] - }, - "call_id": { - "description": "Identifier for the associated command execution item.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory.", - "type": "string" - }, - "network_approval_context": { - "anyOf": [ - { - "$ref": "#/definitions/NetworkApprovalContext" - }, - { - "type": "null" - } - ], - "description": "Optional network context for a blocked request that can be approved." - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "proposed_execpolicy_amendment": { - "description": "Proposed execpolicy amendment that can be applied to allow future runs.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "reason": { - "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "exec_approval_request" - ], - "title": "ExecApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "type" - ], - "title": "ExecApprovalRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated tool call, if available.", - "type": "string" - }, - "questions": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestion" - }, - "type": "array" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "request_user_input" - ], - "title": "RequestUserInputEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "questions", - "type" - ], - "title": "RequestUserInputEventMsg", - "type": "object" - }, - { - "properties": { - "arguments": true, - "callId": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "turnId": { - "type": "string" - }, - "type": { - "enum": [ - "dynamic_tool_call_request" - ], - "title": "DynamicToolCallRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "arguments", - "callId", - "tool", - "turnId", - "type" - ], - "title": "DynamicToolCallRequestEventMsg", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "message": { - "type": "string" - }, - "server_name": { - "type": "string" - }, - "type": { - "enum": [ - "elicitation_request" - ], - "title": "ElicitationRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "message", - "server_name", - "type" - ], - "title": "ElicitationRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated patch apply call, if available.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "type": "object" - }, - "grant_root": { - "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", - "type": [ - "string", - "null" - ] - }, - "reason": { - "description": "Optional explanatory reason (e.g. request for extra write access).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", - "type": "string" - }, - "type": { - "enum": [ - "apply_patch_approval_request" - ], - "title": "ApplyPatchApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "changes", - "type" - ], - "title": "ApplyPatchApprovalRequestEventMsg", - "type": "object" - }, - { - "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", - "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", - "type": [ - "string", - "null" - ] - }, - "summary": { - "description": "Concise summary of what is deprecated.", - "type": "string" - }, - "type": { - "enum": [ - "deprecation_notice" - ], - "title": "DeprecationNoticeEventMsgType", - "type": "string" - } - }, - "required": [ - "summary", - "type" - ], - "title": "DeprecationNoticeEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "background_event" - ], - "title": "BackgroundEventEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "BackgroundEventEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "undo_started" - ], - "title": "UndoStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UndoStartedEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "success": { - "type": "boolean" - }, - "type": { - "enum": [ - "undo_completed" - ], - "title": "UndoCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "success", - "type" - ], - "title": "UndoCompletedEventMsg", - "type": "object" - }, - { - "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", - "properties": { - "additional_details": { - "default": null, - "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", - "type": [ - "string", - "null" - ] - }, - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "stream_error" - ], - "title": "StreamErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "StreamErrorEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", - "properties": { - "auto_approved": { - "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", - "type": "boolean" - }, - "call_id": { - "description": "Identifier so this can be paired with the PatchApplyEnd event.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "description": "The changes to be applied.", - "type": "object" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_begin" - ], - "title": "PatchApplyBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "auto_approved", - "call_id", - "changes", - "type" - ], - "title": "PatchApplyBeginEventMsg", - "type": "object" - }, - { - "description": "Notification that a patch application has finished.", - "properties": { - "call_id": { - "description": "Identifier for the PatchApplyBegin that finished.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "default": {}, - "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", - "type": "object" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/PatchApplyStatus" - } - ], - "description": "Completion status for this patch application." - }, - "stderr": { - "description": "Captured stderr (parser errors, IO failures, etc.).", - "type": "string" - }, - "stdout": { - "description": "Captured stdout (summary printed by apply_patch).", - "type": "string" - }, - "success": { - "description": "Whether the patch was applied successfully.", - "type": "boolean" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_end" - ], - "title": "PatchApplyEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "status", - "stderr", - "stdout", - "success", - "type" - ], - "title": "PatchApplyEndEventMsg", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "turn_diff" - ], - "title": "TurnDiffEventMsgType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "TurnDiffEventMsg", - "type": "object" - }, - { - "description": "Response to GetHistoryEntryRequest.", - "properties": { - "entry": { - "anyOf": [ - { - "$ref": "#/definitions/HistoryEntry" - }, - { - "type": "null" - } - ], - "description": "The entry at the requested offset, if available and parseable." - }, - "log_id": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "offset": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "get_history_entry_response" - ], - "title": "GetHistoryEntryResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "log_id", - "offset", - "type" - ], - "title": "GetHistoryEntryResponseEventMsg", - "type": "object" - }, - { - "description": "List of MCP tools available to the agent.", - "properties": { - "auth_statuses": { - "additionalProperties": { - "$ref": "#/definitions/McpAuthStatus" - }, - "description": "Authentication status for each configured MCP server.", - "type": "object" - }, - "resource_templates": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/ResourceTemplate" - }, - "type": "array" - }, - "description": "Known resource templates grouped by server name.", - "type": "object" - }, - "resources": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/Resource" - }, - "type": "array" - }, - "description": "Known resources grouped by server name.", - "type": "object" - }, - "tools": { - "additionalProperties": { - "$ref": "#/definitions/Tool" - }, - "description": "Fully qualified tool name -> tool definition.", - "type": "object" - }, - "type": { - "enum": [ - "mcp_list_tools_response" - ], - "title": "McpListToolsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "auth_statuses", - "resource_templates", - "resources", - "tools", - "type" - ], - "title": "McpListToolsResponseEventMsg", - "type": "object" - }, - { - "description": "List of custom prompts available to the agent.", - "properties": { - "custom_prompts": { - "items": { - "$ref": "#/definitions/CustomPrompt" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_custom_prompts_response" - ], - "title": "ListCustomPromptsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "custom_prompts", - "type" - ], - "title": "ListCustomPromptsResponseEventMsg", - "type": "object" - }, - { - "description": "List of skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/SkillsListEntry" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_skills_response" - ], - "title": "ListSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "List of remote skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_remote_skills_response" - ], - "title": "ListRemoteSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListRemoteSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "Remote skill downloaded to local cache.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "remote_skill_downloaded" - ], - "title": "RemoteSkillDownloadedEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "name", - "path", - "type" - ], - "title": "RemoteSkillDownloadedEventMsg", - "type": "object" - }, - { - "description": "Notification that skill data may have been updated and clients may want to reload.", - "properties": { - "type": { - "enum": [ - "skills_update_available" - ], - "title": "SkillsUpdateAvailableEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SkillsUpdateAvailableEventMsg", - "type": "object" - }, - { - "properties": { - "explanation": { - "default": null, - "description": "Arguments for the `update_plan` todo/checklist tool (not plan mode).", - "type": [ - "string", - "null" - ] - }, - "plan": { - "items": { - "$ref": "#/definitions/PlanItemArg" - }, - "type": "array" - }, - "type": { - "enum": [ - "plan_update" - ], - "title": "PlanUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "plan", - "type" - ], - "title": "PlanUpdateEventMsg", - "type": "object" - }, - { - "properties": { - "reason": { - "$ref": "#/definitions/TurnAbortReason" - }, - "turn_id": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "turn_aborted" - ], - "title": "TurnAbortedEventMsgType", - "type": "string" - } - }, - "required": [ - "reason", - "type" - ], - "title": "TurnAbortedEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is shutting down.", - "properties": { - "type": { - "enum": [ - "shutdown_complete" - ], - "title": "ShutdownCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ShutdownCompleteEventMsg", - "type": "object" - }, - { - "description": "Entered review mode.", - "properties": { - "target": { - "$ref": "#/definitions/ReviewTarget" - }, - "type": { - "enum": [ - "entered_review_mode" - ], - "title": "EnteredReviewModeEventMsgType", - "type": "string" - }, - "user_facing_hint": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "target", - "type" - ], - "title": "EnteredReviewModeEventMsg", - "type": "object" - }, - { - "description": "Exited review mode with an optional final result to apply.", - "properties": { - "review_output": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewOutputEvent" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "exited_review_mode" - ], - "title": "ExitedReviewModeEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExitedReviewModeEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/ResponseItem" - }, - "type": { - "enum": [ - "raw_response_item" - ], - "title": "RawResponseItemEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "type" - ], - "title": "RawResponseItemEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_started" - ], - "title": "ItemStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemStartedEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_completed" - ], - "title": "ItemCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemCompletedEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_content_delta" - ], - "title": "AgentMessageContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "AgentMessageContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "plan_delta" - ], - "title": "PlanDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "PlanDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_content_delta" - ], - "title": "ReasoningContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "content_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_raw_content_delta" - ], - "title": "ReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_spawn_begin" - ], - "title": "CollabAgentSpawnBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "type" - ], - "title": "CollabAgentSpawnBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "new_agent_nickname": { - "description": "Optional nickname assigned to the new agent.", - "type": [ - "string", - "null" - ] - }, - "new_agent_role": { - "description": "Optional role assigned to the new agent.", - "type": [ - "string", - "null" - ] - }, - "new_thread_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ], - "description": "Thread ID of the newly spawned agent, if it was created." - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the new agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_spawn_end" - ], - "title": "CollabAgentSpawnEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentSpawnEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_interaction_begin" - ], - "title": "CollabAgentInteractionBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabAgentInteractionBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_interaction_end" - ], - "title": "CollabAgentInteractionEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentInteractionEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting begin.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "receiver_agents": { - "description": "Optional nicknames/roles for receivers.", - "items": { - "$ref": "#/definitions/CollabAgentRef" - }, - "type": "array" - }, - "receiver_thread_ids": { - "description": "Thread ID of the receivers.", - "items": { - "$ref": "#/definitions/ThreadId" - }, - "type": "array" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_waiting_begin" - ], - "title": "CollabWaitingBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_ids", - "sender_thread_id", - "type" - ], - "title": "CollabWaitingBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting end.", - "properties": { - "agent_statuses": { - "description": "Optional receiver metadata paired with final statuses.", - "items": { - "$ref": "#/definitions/CollabAgentStatusEntry" - }, - "type": "array" - }, - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "statuses": { - "additionalProperties": { - "$ref": "#/definitions/AgentStatus" - }, - "description": "Last known status of the receiver agents reported to the sender agent.", - "type": "object" - }, - "type": { - "enum": [ - "collab_waiting_end" - ], - "title": "CollabWaitingEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "sender_thread_id", - "statuses", - "type" - ], - "title": "CollabWaitingEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_close_begin" - ], - "title": "CollabCloseBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabCloseBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent before the close." - }, - "type": { - "enum": [ - "collab_close_end" - ], - "title": "CollabCloseEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabCloseEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_resume_begin" - ], - "title": "CollabResumeBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabResumeBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_agent_nickname": { - "description": "Optional nickname assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_agent_role": { - "description": "Optional role assigned to the receiver agent.", - "type": [ - "string", - "null" - ] - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent after resume." - }, - "type": { - "enum": [ - "collab_resume_end" - ], - "title": "CollabResumeEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabResumeEndEventMsg", - "type": "object" - } - ] - }, - "ExecCommandSource": { - "enum": [ - "agent", - "user_shell", - "unified_exec_startup", - "unified_exec_interaction" - ], - "type": "string" - }, - "ExecCommandStatus": { - "enum": [ - "completed", - "failed", - "declined" - ], - "type": "string" - }, - "ExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" - }, - "FileChange": { - "oneOf": [ - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "add" - ], - "title": "AddFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "AddFileChange", - "type": "object" - }, - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "delete" - ], - "title": "DeleteFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "DeleteFileChange", - "type": "object" - }, - { - "properties": { - "move_path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "update" - ], - "title": "UpdateFileChangeType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "UpdateFileChange", - "type": "object" - } - ] - }, - "FunctionCallOutputBody": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" - }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, - "HistoryEntry": { - "properties": { - "conversation_id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "ts": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "conversation_id", - "text", - "ts" - ], - "type": "object" - }, - "LocalShellAction": { - "oneOf": [ - { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "exec" - ], - "title": "ExecLocalShellActionType", - "type": "string" - }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "command", - "type" - ], - "title": "ExecLocalShellAction", - "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "McpAuthStatus": { - "enum": [ - "unsupported", - "not_logged_in", - "bearer_token", - "o_auth" - ], - "type": "string" - }, - "McpInvocation": { - "properties": { - "arguments": { - "description": "Arguments to the tool call." - }, - "server": { - "description": "Name of the MCP server as defined in the config.", - "type": "string" - }, - "tool": { - "description": "Name of the tool as given by the MCP server.", - "type": "string" - } - }, - "required": [ - "server", - "tool" - ], - "type": "object" - }, - "McpStartupFailure": { - "properties": { - "error": { - "type": "string" - }, - "server": { - "type": "string" - } - }, - "required": [ - "error", - "server" - ], - "type": "object" - }, - "McpStartupStatus": { - "oneOf": [ - { - "properties": { - "state": { - "enum": [ - "starting" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus", - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "ready" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus2", - "type": "object" - }, - { - "properties": { - "error": { - "type": "string" - }, - "state": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "error", - "state" - ], - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "cancelled" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus3", - "type": "object" - } - ] - }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], - "type": "string" - }, - { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" - ], - "type": "string" - }, - "ModelRerouteReason": { - "enum": [ - "high_risk_cyber_activity" - ], - "type": "string" - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "NetworkApprovalContext": { - "properties": { - "host": { - "type": "string" - }, - "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" - } - }, - "required": [ - "host", - "protocol" - ], - "type": "object" - }, - "NetworkApprovalProtocol": { - "enum": [ - "http", - "https", - "socks5_tcp", - "socks5_udp" - ], - "type": "string" - }, - "ParsedCommand": { - "oneOf": [ - { - "properties": { - "cmd": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", - "type": "string" - }, - "type": { - "enum": [ - "read" - ], - "title": "ReadParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "name", - "path", - "type" - ], - "title": "ReadParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "list_files" - ], - "title": "ListFilesParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ListFilesParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "SearchParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "unknown" - ], - "title": "UnknownParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "UnknownParsedCommand", - "type": "object" - } - ] - }, - "PatchApplyStatus": { - "enum": [ - "completed", - "failed", - "declined" - ], - "type": "string" - }, - "PlanItemArg": { - "additionalProperties": false, - "properties": { - "status": { - "$ref": "#/definitions/StepStatus" - }, - "step": { - "type": "string" - } - }, - "required": [ - "status", - "step" - ], - "type": "object" - }, - "PlanType": { - "enum": [ - "free", - "go", - "plus", - "pro", - "team", - "business", - "enterprise", - "edu", - "unknown" - ], - "type": "string" - }, - "RateLimitSnapshot": { - "properties": { - "credits": { - "anyOf": [ - { - "$ref": "#/definitions/CreditsSnapshot" - }, - { - "type": "null" - } - ] - }, - "limit_id": { - "type": [ - "string", - "null" - ] - }, - "limit_name": { - "type": [ - "string", - "null" - ] - }, - "plan_type": { - "anyOf": [ - { - "$ref": "#/definitions/PlanType" - }, - { - "type": "null" - } - ] - }, - "primary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - }, - "secondary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RateLimitWindow": { - "properties": { - "resets_at": { - "description": "Unix timestamp (seconds since epoch) when the window resets.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "used_percent": { - "description": "Percentage (0-100) of the window that has been consumed.", - "format": "double", - "type": "number" - }, - "window_minutes": { - "description": "Rolling window duration, in minutes.", - "format": "int64", - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "used_percent" - ], - "type": "object" - }, - "ReadOnlyAccess": { - "description": "Determines how read-only file access is granted inside a restricted sandbox.", - "oneOf": [ - { - "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", - "properties": { - "include_platform_defaults": { - "default": true, - "description": "Include built-in platform read roots required for basic process execution.", - "type": "boolean" - }, - "readable_roots": { - "description": "Additional absolute roots that should be readable.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "description": "Allow unrestricted file reads.", - "properties": { - "type": { - "enum": [ - "full-access" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, - "RealtimeAudioFrame": { - "properties": { - "data": { - "type": "string" - }, - "num_channels": { - "format": "uint16", - "minimum": 0.0, - "type": "integer" - }, - "sample_rate": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "samples_per_channel": { - "format": "uint32", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "data", - "num_channels", - "sample_rate" - ], - "type": "object" - }, - "RealtimeEvent": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "SessionCreated": { - "properties": { - "session_id": { - "type": "string" - } - }, - "required": [ - "session_id" - ], - "type": "object" - } - }, - "required": [ - "SessionCreated" - ], - "title": "SessionCreatedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "SessionUpdated": { - "properties": { - "backend_prompt": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "SessionUpdated" - ], - "title": "SessionUpdatedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "AudioOut": { - "$ref": "#/definitions/RealtimeAudioFrame" - } - }, - "required": [ - "AudioOut" - ], - "title": "AudioOutRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "ConversationItemAdded": true - }, - "required": [ - "ConversationItemAdded" - ], - "title": "ConversationItemAddedRealtimeEvent", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "Error": { - "type": "string" - } - }, - "required": [ - "Error" - ], - "title": "ErrorRealtimeEvent", - "type": "object" - } - ] - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, - "RequestId": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "int64", - "type": "integer" - } - ], - "description": "ID of a request, which can be either a string or an integer." - }, - "RequestUserInputQuestion": { - "properties": { - "header": { - "type": "string" - }, - "id": { - "type": "string" - }, - "isOther": { - "default": false, - "type": "boolean" - }, - "isSecret": { - "default": false, - "type": "boolean" - }, - "options": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestionOption" - }, - "type": [ - "array", - "null" - ] - }, - "question": { - "type": "string" - } - }, - "required": [ - "header", - "id", - "question" - ], - "type": "object" - }, - "RequestUserInputQuestionOption": { - "properties": { - "description": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "description", - "label" - ], - "type": "object" - }, - "Resource": { - "description": "A known resource that the server is capable of reading.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "size": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "name", - "uri" - ], - "type": "object" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server.", - "properties": { - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "type": "string" - } - }, - "required": [ - "name", - "uriTemplate" - ], - "type": "object" - }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" - }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "type": "string" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction" - ], - "title": "CompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionResponseItem", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" - } - ] - }, - "Result_of_CallToolResult_or_String": { - "oneOf": [ - { - "properties": { - "Ok": { - "$ref": "#/definitions/CallToolResult" - } - }, - "required": [ - "Ok" - ], - "title": "OkResult_of_CallToolResult_or_String", - "type": "object" - }, - { - "properties": { - "Err": { - "type": "string" - } - }, - "required": [ - "Err" - ], - "title": "ErrResult_of_CallToolResult_or_String", - "type": "object" - } - ] - }, - "ReviewCodeLocation": { - "description": "Location of the code related to a review finding.", - "properties": { - "absolute_file_path": { - "type": "string" - }, - "line_range": { - "$ref": "#/definitions/ReviewLineRange" - } - }, - "required": [ - "absolute_file_path", - "line_range" - ], - "type": "object" - }, - "ReviewFinding": { - "description": "A single review finding describing an observed issue or recommendation.", - "properties": { - "body": { - "type": "string" - }, - "code_location": { - "$ref": "#/definitions/ReviewCodeLocation" - }, - "confidence_score": { - "format": "float", - "type": "number" - }, - "priority": { - "format": "int32", - "type": "integer" - }, - "title": { - "type": "string" - } - }, - "required": [ - "body", - "code_location", - "confidence_score", - "priority", - "title" - ], - "type": "object" - }, - "ReviewLineRange": { - "description": "Inclusive line range in a file associated with the finding.", - "properties": { - "end": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "ReviewOutputEvent": { - "description": "Structured review result produced by a child review session.", - "properties": { - "findings": { - "items": { - "$ref": "#/definitions/ReviewFinding" - }, - "type": "array" - }, - "overall_confidence_score": { - "format": "float", - "type": "number" - }, - "overall_correctness": { - "type": "string" - }, - "overall_explanation": { - "type": "string" - } - }, - "required": [ - "findings", - "overall_confidence_score", - "overall_correctness", - "overall_explanation" - ], - "type": "object" - }, - "ReviewTarget": { - "oneOf": [ - { - "description": "Review the working tree: staged, unstaged, and untracked files.", - "properties": { - "type": { - "enum": [ - "uncommittedChanges" - ], - "title": "UncommittedChangesReviewTargetType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UncommittedChangesReviewTarget", - "type": "object" - }, - { - "description": "Review changes between the current branch and the given base branch.", - "properties": { - "branch": { - "type": "string" - }, - "type": { - "enum": [ - "baseBranch" - ], - "title": "BaseBranchReviewTargetType", - "type": "string" - } - }, - "required": [ - "branch", - "type" - ], - "title": "BaseBranchReviewTarget", - "type": "object" - }, - { - "description": "Review the changes introduced by a specific commit.", - "properties": { - "sha": { - "type": "string" - }, - "title": { - "description": "Optional human-readable label (e.g., commit subject) for UIs.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "commit" - ], - "title": "CommitReviewTargetType", - "type": "string" - } - }, - "required": [ - "sha", - "type" - ], - "title": "CommitReviewTarget", - "type": "object" - }, - { - "description": "Arbitrary instructions provided by the user.", - "properties": { - "instructions": { - "type": "string" - }, - "type": { - "enum": [ - "custom" - ], - "title": "CustomReviewTargetType", - "type": "string" - } - }, - "required": [ - "instructions", - "type" - ], - "title": "CustomReviewTarget", - "type": "object" - } - ] - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access configuration.", - "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "read_only_access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "description": "Read access granted while running under this policy." - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - }, - "SessionNetworkProxyRuntime": { - "properties": { - "admin_addr": { - "type": "string" - }, - "http_addr": { - "type": "string" - }, - "socks_addr": { - "type": "string" - } - }, - "required": [ - "admin_addr", - "http_addr", - "socks_addr" - ], - "type": "object" - }, - "SkillDependencies": { - "properties": { - "tools": { - "items": { - "$ref": "#/definitions/SkillToolDependency" - }, - "type": "array" - } - }, - "required": [ - "tools" - ], - "type": "object" - }, - "SkillErrorInfo": { - "properties": { - "message": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "message", - "path" - ], - "type": "object" - }, - "SkillInterface": { - "properties": { - "brand_color": { - "type": [ - "string", - "null" - ] - }, - "default_prompt": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "icon_large": { - "type": [ - "string", - "null" - ] - }, - "icon_small": { - "type": [ - "string", - "null" - ] - }, - "short_description": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "SkillMetadata": { - "properties": { - "dependencies": { - "anyOf": [ - { - "$ref": "#/definitions/SkillDependencies" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "interface": { - "anyOf": [ - { - "$ref": "#/definitions/SkillInterface" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "scope": { - "$ref": "#/definitions/SkillScope" - }, - "short_description": { - "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "description", - "enabled", - "name", - "path", - "scope" - ], - "type": "object" - }, - "SkillScope": { - "enum": [ - "user", - "repo", - "system", - "admin" - ], - "type": "string" - }, - "SkillToolDependency": { - "properties": { - "command": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "transport": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "value" - ], - "type": "object" - }, - "SkillsListEntry": { - "properties": { - "cwd": { - "type": "string" - }, - "errors": { - "items": { - "$ref": "#/definitions/SkillErrorInfo" - }, - "type": "array" - }, - "skills": { - "items": { - "$ref": "#/definitions/SkillMetadata" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "errors", - "skills" - ], - "type": "object" - }, - "StepStatus": { - "enum": [ - "pending", - "in_progress", - "completed" - ], - "type": "string" - }, - "TextElement": { - "properties": { - "byte_range": { - "allOf": [ - { - "$ref": "#/definitions/ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byte_range" - ], - "type": "object" - }, - "ThreadId": { - "type": "string" - }, - "TokenUsage": { - "properties": { - "cached_input_tokens": { - "format": "int64", - "type": "integer" - }, - "input_tokens": { - "format": "int64", - "type": "integer" - }, - "output_tokens": { - "format": "int64", - "type": "integer" - }, - "reasoning_output_tokens": { - "format": "int64", - "type": "integer" - }, - "total_tokens": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "cached_input_tokens", - "input_tokens", - "output_tokens", - "reasoning_output_tokens", - "total_tokens" - ], - "type": "object" - }, - "TokenUsageInfo": { - "properties": { - "last_token_usage": { - "$ref": "#/definitions/TokenUsage" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "total_token_usage": { - "$ref": "#/definitions/TokenUsage" - } - }, - "required": [ - "last_token_usage", - "total_token_usage" - ], - "type": "object" - }, - "Tool": { - "description": "Definition for a tool the client can call.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "inputSchema": true, - "name": { - "type": "string" - }, - "outputSchema": true, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "inputSchema", - "name" - ], - "type": "object" - }, - "TurnAbortReason": { - "enum": [ - "interrupted", - "replaced", - "review_ended" - ], - "type": "string" - }, - "TurnItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/UserInput" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "type": { - "enum": [ - "UserMessage" - ], - "title": "UserMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "UserMessageTurnItem", - "type": "object" - }, - { - "description": "Assistant-authored message payload used in turn-item streams.\n\n`phase` is optional because not all providers/models emit it. Consumers should use it when present, but retain legacy completion semantics when it is `None`.", - "properties": { - "content": { - "items": { - "$ref": "#/definitions/AgentMessageContent" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." - }, - "type": { - "enum": [ - "AgentMessage" - ], - "title": "AgentMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "AgentMessageTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Plan" - ], - "title": "PlanTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "text", - "type" - ], - "title": "PlanTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "raw_content": { - "default": [], - "items": { - "type": "string" - }, - "type": "array" - }, - "summary_text": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "Reasoning" - ], - "title": "ReasoningTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary_text", - "type" - ], - "title": "ReasoningTurnItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "WebSearch" - ], - "title": "WebSearchTurnItemType", - "type": "string" - } - }, - "required": [ - "action", - "id", - "query", - "type" - ], - "title": "WebSearchTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "type": { - "enum": [ - "ContextCompaction" - ], - "title": "ContextCompactionTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ContextCompactionTurnItem", - "type": "object" - } - ] - }, - "UserInput": { - "description": "User input", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` that should be treated as special elements. These are byte ranges into the UTF-8 `text` buffer and are used to render or persist rich input markers (e.g., image placeholders) across history and resume without mutating the literal text.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextUserInputType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextUserInput", - "type": "object" - }, - { - "description": "Pre‑encoded data: URI image.", - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageUserInputType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "ImageUserInput", - "type": "object" - }, - { - "description": "Local image path provided by the user. This will be converted to an `Image` variant (base64 data URL) during request serialization.", - "properties": { - "path": { - "type": "string" - }, - "type": { - "enum": [ - "local_image" - ], - "title": "LocalImageUserInputType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "LocalImageUserInput", - "type": "object" - }, - { - "description": "Skill selected by the user (name + path to SKILL.md).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "skill" - ], - "title": "SkillUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "SkillUserInput", - "type": "object" - }, - { - "description": "Explicit mention selected by the user (name + app://connector id).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "mention" - ], - "title": "MentionUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "MentionUserInput", - "type": "object" - } - ] - }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] - } - }, - "properties": { - "historyEntryCount": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "historyLogId": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "rolloutPath": { - "type": "string" - }, - "sessionId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "historyEntryCount", - "historyLogId", - "model", - "rolloutPath", - "sessionId" - ], - "title": "SessionConfiguredNotification", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/SetDefaultModelParams.json b/codex-rs/app-server-protocol/schema/json/v1/SetDefaultModelParams.json deleted file mode 100644 index 3027420212..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/SetDefaultModelParams.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - } - }, - "properties": { - "model": { - "type": [ - "string", - "null" - ] - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - } - }, - "title": "SetDefaultModelParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/SetDefaultModelResponse.json b/codex-rs/app-server-protocol/schema/json/v1/SetDefaultModelResponse.json deleted file mode 100644 index bb61dada65..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/SetDefaultModelResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SetDefaultModelResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v1/UserInfoResponse.json b/codex-rs/app-server-protocol/schema/json/v1/UserInfoResponse.json deleted file mode 100644 index 617f6b6706..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v1/UserInfoResponse.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "allegedUserEmail": { - "type": [ - "string", - "null" - ] - } - }, - "title": "UserInfoResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 1a4edfdf57..b6c8da90ea 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -32,93 +32,41 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::process::Command; -use std::sync::LazyLock; use ts_rs::TS; const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; -static VARIANT_TITLE_COLLISION_OVERRIDES: LazyLock> = - LazyLock::new(|| { - HashMap::from([ - ( - "base=McpStartupStatus|generated=StateMcpStartupStatus|literal:state=ready|only_property=state|required_only=state", - "StateMcpStartupStatus2", - ), - ( - "base=McpStartupStatus|generated=StateMcpStartupStatus|literal:state=cancelled|only_property=state|required_only=state", - "StateMcpStartupStatus3", - ), - ]) - }); -static NUMBERED_DEFINITION_COLLISION_OVERRIDES: LazyLock> = - LazyLock::new(|| { - HashMap::from([ - ( - "schema=ClientRequest|container=definitions|generated=AskForApproval2|base=AskForApproval", - "AskForApproval2", - ), - ( - "schema=ClientRequest|container=definitions|generated=NetworkAccess2|base=NetworkAccess", - "NetworkAccess2", - ), - ( - "schema=ClientRequest|container=definitions|generated=ReadOnlyAccess2|base=ReadOnlyAccess", - "ReadOnlyAccess2", - ), - ( - "schema=ClientRequest|container=definitions|generated=SandboxMode2|base=SandboxMode", - "SandboxMode2", - ), - ( - "schema=ClientRequest|container=definitions|generated=SandboxPolicy2|base=SandboxPolicy", - "SandboxPolicy2", - ), - ( - "schema=ServerNotification|container=definitions|generated=ByteRange2|base=ByteRange", - "ByteRange2", - ), - ( - "schema=ServerNotification|container=definitions|generated=CodexErrorInfo2|base=CodexErrorInfo", - "CodexErrorInfo2", - ), - ( - "schema=ServerNotification|container=definitions|generated=CreditsSnapshot2|base=CreditsSnapshot", - "CreditsSnapshot2", - ), - ( - "schema=ServerNotification|container=definitions|generated=MessagePhase2|base=MessagePhase", - "MessagePhase2", - ), - ( - "schema=ServerNotification|container=definitions|generated=ModelRerouteReason2|base=ModelRerouteReason", - "ModelRerouteReason2", - ), - ( - "schema=ServerNotification|container=definitions|generated=PatchApplyStatus2|base=PatchApplyStatus", - "PatchApplyStatus2", - ), - ( - "schema=ServerNotification|container=definitions|generated=RateLimitSnapshot2|base=RateLimitSnapshot", - "RateLimitSnapshot2", - ), - ( - "schema=ServerNotification|container=definitions|generated=RateLimitWindow2|base=RateLimitWindow", - "RateLimitWindow2", - ), - ( - "schema=ServerNotification|container=definitions|generated=TextElement2|base=TextElement", - "TextElement2", - ), - ( - "schema=ServerNotification|container=definitions|generated=UserInput2|base=UserInput", - "UserInput2", - ), - ( - "schema=ServerNotification|container=definitions|generated=WebSearchAction2|base=WebSearchAction", - "WebSearchAction2", - ), - ]) - }); +const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"]; +const V1_CLIENT_REQUEST_METHODS: &[&str] = &[ + "newConversation", + "getConversationSummary", + "listConversations", + "resumeConversation", + "forkConversation", + "archiveConversation", + "sendUserMessage", + "sendUserTurn", + "interruptConversation", + "addConversationListener", + "removeConversationListener", + "gitDiffToRemote", + "loginApiKey", + "loginChatGpt", + "cancelLoginChatGpt", + "logoutChatGpt", + "getAuthStatus", + "getUserSavedConfig", + "setDefaultModel", + "getUserAgent", + "userInfo", + "execOneOffCommand", +]; +const EXCLUDED_SERVER_NOTIFICATION_METHODS_FOR_JSON: &[&str] = &[ + "authStatusChange", + "loginChatGptComplete", + "sessionConfigured", + "rawResponseItem/completed", +]; #[derive(Clone)] pub struct GeneratedSchema { @@ -264,6 +212,8 @@ pub fn generate_json_with_experimental(out_dir: &Path, experimental_api: bool) - schemas.extend(export_server_response_schemas(out_dir)?); schemas.extend(export_client_notification_schemas(out_dir)?); schemas.extend(export_server_notification_schemas(out_dir)?); + schemas + .retain(|schema| !schema.in_v1_dir || JSON_V1_ALLOWLIST.contains(&schema.logical_name())); let mut bundle = build_schema_bundle(schemas)?; if !experimental_api { @@ -1031,14 +981,23 @@ where T: JsonSchema, { let file_stem = name.trim(); + let (raw_namespace, logical_name) = split_namespace(file_stem); + let include_in_json_codegen = + raw_namespace != Some("v1") || JSON_V1_ALLOWLIST.contains(&logical_name); let schema = schema_for!(T); let mut schema_value = serde_json::to_value(schema)?; - enforce_numbered_definition_collision_overrides(file_stem, &mut schema_value); - annotate_schema(&mut schema_value, Some(file_stem)); + if include_in_json_codegen { + if file_stem == "ClientRequest" { + strip_v1_client_request_variants_from_json_schema(&mut schema_value); + } else if file_stem == "ServerNotification" { + strip_v1_server_notification_variants_from_json_schema(&mut schema_value); + } + enforce_numbered_definition_collision_overrides(file_stem, &mut schema_value); + annotate_schema(&mut schema_value, Some(file_stem)); + } // If the name looks like a namespaced path (e.g., "v2::Type"), mirror // the TypeScript layout and write to out_dir/v2/Type.json. Otherwise // write alongside the legacy files. - let (raw_namespace, logical_name) = split_namespace(file_stem); let out_path = if let Some(ns) = raw_namespace { let dir = out_dir.join(ns); ensure_dir(&dir)?; @@ -1047,7 +1006,7 @@ where out_dir.join(format!("{file_stem}.json")) }; - if !IGNORED_DEFINITIONS.contains(&logical_name) { + if include_in_json_codegen && !IGNORED_DEFINITIONS.contains(&logical_name) { write_pretty_json(out_path, &schema_value) .with_context(|| format!("Failed to write JSON schema for {file_stem}"))?; } @@ -1066,96 +1025,162 @@ where fn enforce_numbered_definition_collision_overrides(schema_name: &str, schema: &mut Value) { for defs_key in ["definitions", "$defs"] { - let renames = { - let Some(defs) = schema.get(defs_key).and_then(Value::as_object) else { - continue; - }; - collect_numbered_definition_collision_renames(schema_name, defs_key, defs) - }; - - if renames.is_empty() { + let Some(defs) = schema.get(defs_key).and_then(Value::as_object) else { continue; - } - - #[expect(clippy::expect_used)] - let defs = schema - .get_mut(defs_key) - .and_then(Value::as_object_mut) - .expect("definition map should still be present"); - for (old_name, new_name, collision_key) in &renames { - if old_name == new_name { - continue; - } - assert!( - !defs.contains_key(new_name), - "Numbered definition collision override produced duplicate definition {new_name} for {collision_key}", - ); - let schema_value = defs.remove(old_name).unwrap_or_else(|| { - panic!("Missing generated numbered definition {old_name} for {collision_key}") - }); - defs.insert(new_name.clone(), schema_value); - } - - for (old_name, new_name, _) in renames { - if old_name != new_name { - rewrite_named_local_ref(schema, defs_key, &old_name, &new_name); - } - } + }; + detect_numbered_definition_collisions(schema_name, defs_key, defs); } } -fn collect_numbered_definition_collision_renames( +fn strip_v1_client_request_variants_from_json_schema(schema: &mut Value) { + let v1_methods: HashSet<&str> = V1_CLIENT_REQUEST_METHODS.iter().copied().collect(); + strip_method_variants_from_json_schema(schema, &v1_methods); +} + +fn strip_v1_server_notification_variants_from_json_schema(schema: &mut Value) { + let methods: HashSet<&str> = EXCLUDED_SERVER_NOTIFICATION_METHODS_FOR_JSON + .iter() + .copied() + .collect(); + strip_method_variants_from_json_schema(schema, &methods); +} + +fn strip_method_variants_from_json_schema(schema: &mut Value, methods_to_remove: &HashSet<&str>) { + { + let Some(root) = schema.as_object_mut() else { + return; + }; + let Some(Value::Array(variants)) = root.get_mut("oneOf") else { + return; + }; + variants.retain(|variant| !is_method_variant_in_set(variant, methods_to_remove)); + } + + let reachable = reachable_local_definitions(schema, "definitions"); + let Some(root) = schema.as_object_mut() else { + return; + }; + if let Some(definitions) = root.get_mut("definitions").and_then(Value::as_object_mut) { + definitions.retain(|name, _| reachable.contains(name)); + } +} + +fn is_method_variant_in_set(value: &Value, methods: &HashSet<&str>) -> bool { + let Value::Object(map) = value else { + return false; + }; + let Some(properties) = map.get("properties").and_then(Value::as_object) else { + return false; + }; + let Some(method_schema) = properties.get("method") else { + return false; + }; + let Some(method) = string_literal(method_schema) else { + return false; + }; + methods.contains(method) +} + +fn reachable_local_definitions(schema: &Value, defs_key: &str) -> HashSet { + let Some(definitions) = schema.get(defs_key).and_then(Value::as_object) else { + return HashSet::new(); + }; + let mut queue: Vec = Vec::new(); + let mut reachable: HashSet = HashSet::new(); + + collect_local_definition_refs_excluding_maps(schema, defs_key, &mut queue, &mut reachable); + + while let Some(name) = queue.pop() { + if let Some(def_schema) = definitions.get(&name) { + collect_local_definition_refs(def_schema, defs_key, &mut queue, &mut reachable); + } + } + reachable +} + +fn collect_local_definition_refs_excluding_maps( + value: &Value, + defs_key: &str, + queue: &mut Vec, + reachable: &mut HashSet, +) { + match value { + Value::Object(map) => { + for (key, child) in map { + if key == defs_key || key == "$defs" || key == "definitions" { + continue; + } + collect_local_definition_refs_excluding_maps(child, defs_key, queue, reachable); + } + } + Value::Array(items) => { + for child in items { + collect_local_definition_refs_excluding_maps(child, defs_key, queue, reachable); + } + } + _ => {} + } + collect_local_definition_ref_here(value, defs_key, queue, reachable); +} + +fn collect_local_definition_refs( + value: &Value, + defs_key: &str, + queue: &mut Vec, + reachable: &mut HashSet, +) { + collect_local_definition_ref_here(value, defs_key, queue, reachable); + match value { + Value::Object(map) => { + for child in map.values() { + collect_local_definition_refs(child, defs_key, queue, reachable); + } + } + Value::Array(items) => { + for child in items { + collect_local_definition_refs(child, defs_key, queue, reachable); + } + } + _ => {} + } +} + +fn collect_local_definition_ref_here( + value: &Value, + defs_key: &str, + queue: &mut Vec, + reachable: &mut HashSet, +) { + let Some(reference) = value + .as_object() + .and_then(|obj| obj.get("$ref")) + .and_then(Value::as_str) + else { + return; + }; + let Some(name) = reference.strip_prefix(&format!("#/{defs_key}/")) else { + return; + }; + let name = name.split('/').next().unwrap_or(name); + if reachable.insert(name.to_string()) { + queue.push(name.to_string()); + } +} + +fn detect_numbered_definition_collisions( schema_name: &str, defs_key: &str, defs: &Map, -) -> Vec<(String, String, String)> { - let mut renames = Vec::new(); - +) { for generated_name in defs.keys() { let base_name = generated_name.trim_end_matches(|c: char| c.is_ascii_digit()); if base_name == generated_name || !defs.contains_key(base_name) { continue; } - let collision_key = format!( - "schema={schema_name}|container={defs_key}|generated={generated_name}|base={base_name}" + panic!( + "Numbered definition naming collision detected: schema={schema_name}|container={defs_key}|generated={generated_name}|base={base_name}" ); - let replacement = NUMBERED_DEFINITION_COLLISION_OVERRIDES - .get(collision_key.as_str()) - .map(|value| (*value).to_string()) - .unwrap_or_else(|| { - panic!("Missing numbered definition collision override for {collision_key}") - }); - renames.push((generated_name.clone(), replacement, collision_key)); - } - - renames -} - -fn rewrite_named_local_ref(value: &mut Value, defs_key: &str, old_name: &str, new_name: &str) { - let direct = format!("#/{defs_key}/{old_name}"); - let prefixed = format!("{direct}/"); - let replacement = format!("#/{defs_key}/{new_name}"); - let replacement_prefixed = format!("{replacement}/"); - match value { - Value::Object(obj) => { - if let Some(Value::String(reference)) = obj.get_mut("$ref") { - if reference == &direct { - *reference = replacement; - } else if let Some(rest) = reference.strip_prefix(&prefixed) { - *reference = format!("{replacement_prefixed}{rest}"); - } - } - for child in obj.values_mut() { - rewrite_named_local_ref(child, defs_key, old_name, new_name); - } - } - Value::Array(items) => { - for child in items { - rewrite_named_local_ref(child, defs_key, old_name, new_name); - } - } - _ => {} } } @@ -1263,7 +1288,11 @@ fn variant_definition_name(base: &str, variant: &Value) -> Option { if props.len() == 1 && let Some(key) = props.keys().next() { - let pascal = to_pascal_case(key); + let pascal = props + .get(key) + .and_then(string_literal) + .map(to_pascal_case) + .unwrap_or_else(|| to_pascal_case(key)); return Some(format!("{pascal}{base}")); } } @@ -1376,20 +1405,11 @@ fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) { && let Some(base_name) = base && let Some(name) = variant_definition_name(base_name, variant) { - let mut candidate = name.clone(); + let candidate = name.clone(); if seen.contains(&candidate) { let collision_key = variant_title_collision_key(base_name, &name, variant); - candidate = VARIANT_TITLE_COLLISION_OVERRIDES - .get(collision_key.as_str()) - .map(|value| (*value).to_string()) - .unwrap_or_else(|| { - panic!( - "Missing variant title collision override for {collision_key} (generated name: {name})" - ) - }); - assert!( - !seen.contains(&candidate), - "Variant title collision override produced duplicate title {candidate} for {collision_key}", + panic!( + "Variant title naming collision detected: {collision_key} (generated name: {name})" ); } if let Some(obj) = variant.as_object_mut() { From a8b4b569fbc92e910a9906178c530fdf4aa9a2ad Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 20 Feb 2026 22:02:35 -0800 Subject: [PATCH 027/282] fix(core) Filter non-matching prefix rules (#12314) ## Summary `gpt-5.3-codex` really likes to write complicated shell scripts, and suggest a partial prefix_rule that wouldn't actually approve the command. We should only show the `prefix_rule` suggestion from the model if it would actually fully approve the command the user is seeing. This will technically cause more instances of overly-specific suggestions when we fallback, but I think the UX is clearer, particularly when the model doesn't necessarily understand the current limitations of execpolicy parsing. ## Testing - [x] Add unit tests - [x] Add integration tests --- codex-rs/core/src/exec_policy.rs | 113 ++++++++++++++-- codex-rs/core/tests/suite/approvals.rs | 178 +++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index f2cedb3f94..52329a255f 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -224,6 +224,9 @@ impl ExecPolicyManager { let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule( prefix_rule.as_ref(), &evaluation.matched_rules, + exec_policy.as_ref(), + &commands, + &exec_policy_fallback, ); match evaluation.decision { @@ -592,6 +595,9 @@ fn try_derive_execpolicy_amendment_for_allow_rules( fn derive_requested_execpolicy_amendment_from_prefix_rule( prefix_rule: Option<&Vec>, matched_rules: &[RuleMatch], + exec_policy: &Policy, + commands: &[Vec], + exec_policy_fallback: &impl Fn(&[String]) -> Decision, ) -> Option { let prefix_rule = prefix_rule?; if prefix_rule.is_empty() { @@ -612,7 +618,39 @@ fn derive_requested_execpolicy_amendment_from_prefix_rule( return None; } - Some(ExecPolicyAmendment::new(prefix_rule.clone())) + let amendment = ExecPolicyAmendment::new(prefix_rule.clone()); + if prefix_rule_would_approve_all_commands( + exec_policy, + &amendment.command, + commands, + exec_policy_fallback, + ) { + Some(amendment) + } else { + None + } +} + +fn prefix_rule_would_approve_all_commands( + exec_policy: &Policy, + prefix_rule: &[String], + commands: &[Vec], + exec_policy_fallback: &impl Fn(&[String]) -> Decision, +) -> bool { + let mut policy_with_prefix_rule = exec_policy.clone(); + if policy_with_prefix_rule + .add_prefix_rule(prefix_rule, Decision::Allow) + .is_err() + { + return false; + } + + commands.iter().all(|command| { + policy_with_prefix_rule + .check(command, exec_policy_fallback) + .decision + == Decision::Allow + }) } /// Only return a reason when a policy rule drove the prompt decision. @@ -1125,7 +1163,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") } #[tokio::test] - async fn keeps_requested_amendment_for_heredoc_fallback_prompts() { + async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_match() { let command = vec![ "bash".to_string(), "-lc".to_string(), @@ -1147,7 +1185,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(requested_prefix)), + proposed_execpolicy_amendment: None, } ); } @@ -1472,6 +1510,38 @@ prefix_rule( ); } + #[tokio::test] + async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cargo install cargo-insta && rm -rf /tmp/codex".to_string(), + ]; + let manager = ExecPolicyManager::default(); + + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + sandbox_permissions: SandboxPermissions::RequireEscalated, + prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), + }) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "rm".to_string(), + "-rf".to_string(), + "/tmp/codex".to_string(), + ])), + } + ); + } + #[tokio::test] async fn heuristics_apply_when_other_commands_match_policy() { let policy_src = r#"prefix_rule(pattern=["apple"], decision="allow")"#; @@ -1728,11 +1798,28 @@ prefix_rule( ); } + fn derive_requested_execpolicy_amendment_for_test( + prefix_rule: Option<&Vec>, + matched_rules: &[RuleMatch], + ) -> Option { + let commands = prefix_rule + .cloned() + .map(|prefix_rule| vec![prefix_rule]) + .unwrap_or_else(|| vec![vec!["echo".to_string()]]); + derive_requested_execpolicy_amendment_from_prefix_rule( + prefix_rule, + matched_rules, + &Policy::empty(), + &commands, + &|_: &[String]| Decision::Allow, + ) + } + #[test] fn derive_requested_execpolicy_amendment_returns_none_for_missing_prefix_rule() { assert_eq!( None, - derive_requested_execpolicy_amendment_from_prefix_rule(None, &[]) + derive_requested_execpolicy_amendment_for_test(None, &[]) ); } @@ -1740,7 +1827,7 @@ prefix_rule( fn derive_requested_execpolicy_amendment_returns_none_for_empty_prefix_rule() { assert_eq!( None, - derive_requested_execpolicy_amendment_from_prefix_rule(Some(&Vec::new()), &[]) + derive_requested_execpolicy_amendment_for_test(Some(&Vec::new()), &[]) ); } @@ -1748,7 +1835,7 @@ prefix_rule( fn derive_requested_execpolicy_amendment_returns_none_for_exact_banned_prefix_rule() { assert_eq!( None, - derive_requested_execpolicy_amendment_from_prefix_rule( + derive_requested_execpolicy_amendment_for_test( Some(&vec!["python".to_string(), "-c".to_string()]), &[], ) @@ -1767,7 +1854,7 @@ prefix_rule( ] { assert_eq!( None, - derive_requested_execpolicy_amendment_from_prefix_rule(Some(&prefix_rule), &[]) + derive_requested_execpolicy_amendment_for_test(Some(&prefix_rule), &[]) ); } } @@ -1793,7 +1880,7 @@ prefix_rule( ] { assert_eq!( None, - derive_requested_execpolicy_amendment_from_prefix_rule(Some(&prefix_rule), &[]) + derive_requested_execpolicy_amendment_for_test(Some(&prefix_rule), &[]) ); } } @@ -1808,7 +1895,7 @@ prefix_rule( assert_eq!( Some(ExecPolicyAmendment::new(prefix_rule.clone())), - derive_requested_execpolicy_amendment_from_prefix_rule(Some(&prefix_rule), &[]) + derive_requested_execpolicy_amendment_for_test(Some(&prefix_rule), &[]) ); } @@ -1823,7 +1910,7 @@ prefix_rule( }]; assert_eq!( None, - derive_requested_execpolicy_amendment_from_prefix_rule( + derive_requested_execpolicy_amendment_for_test( Some(&prefix_rule), &matched_rules_prompt ), @@ -1836,7 +1923,7 @@ prefix_rule( }]; assert_eq!( None, - derive_requested_execpolicy_amendment_from_prefix_rule( + derive_requested_execpolicy_amendment_for_test( Some(&prefix_rule), &matched_rules_allow ), @@ -1849,9 +1936,9 @@ prefix_rule( }]; assert_eq!( None, - derive_requested_execpolicy_amendment_from_prefix_rule( + derive_requested_execpolicy_amendment_for_test( Some(&prefix_rule), - &matched_rules_forbidden + &matched_rules_forbidden, ), "should return none when prompt policy matches" ); diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 14d9c79e5e..374cc1ada7 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -184,6 +184,16 @@ fn shell_event( command: &str, timeout_ms: u64, sandbox_permissions: SandboxPermissions, +) -> Result { + shell_event_with_prefix_rule(call_id, command, timeout_ms, sandbox_permissions, None) +} + +fn shell_event_with_prefix_rule( + call_id: &str, + command: &str, + timeout_ms: u64, + sandbox_permissions: SandboxPermissions, + prefix_rule: Option>, ) -> Result { let mut args = json!({ "command": command, @@ -192,6 +202,9 @@ fn shell_event( if sandbox_permissions.requires_escalated_permissions() { args["sandbox_permissions"] = json!(sandbox_permissions); } + if let Some(prefix_rule) = prefix_rule { + args["prefix_rule"] = json!(prefix_rule); + } let args_str = serde_json::to_string(&args)?; Ok(ev_function_call(call_id, "shell_command", &args_str)) } @@ -1928,6 +1941,171 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts Ok(()) } +#[tokio::test(flavor = "current_thread")] +#[cfg(unix)] +async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Result<()> { + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let sandbox_policy_for_config = sandbox_policy.clone(); + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + }); + let test = builder.build(&server).await?; + + let call_id = "invalid-prefix-rule"; + let command = + "touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt"; + let event = shell_event_with_prefix_rule( + call_id, + command, + 1_000, + SandboxPermissions::RequireEscalated, + Some(vec!["touch".to_string()]), + )?; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-invalid-prefix-1"), + event, + ev_completed("resp-invalid-prefix-1"), + ]), + ) + .await; + + submit_turn( + &test, + "invalid-prefix-rule", + approval_policy, + sandbox_policy.clone(), + ) + .await?; + + let approval = expect_exec_approval(&test, command).await; + let amendment = approval + .proposed_execpolicy_amendment + .expect("should have a proposed execpolicy amendment"); + assert!(amendment.command.contains(&command.to_string())); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +#[cfg(unix)] +async fn approving_fallback_rule_for_compound_command_works() -> Result<()> { + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let sandbox_policy_for_config = sandbox_policy.clone(); + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + }); + let test = builder.build(&server).await?; + + let call_id = "invalid-prefix-rule"; + let command = + "touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt"; + let event = shell_event_with_prefix_rule( + call_id, + command, + 1_000, + SandboxPermissions::RequireEscalated, + Some(vec!["touch".to_string()]), + )?; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-invalid-prefix-1"), + event, + ev_completed("resp-invalid-prefix-1"), + ]), + ) + .await; + + submit_turn( + &test, + "invalid-prefix-rule", + approval_policy, + sandbox_policy.clone(), + ) + .await?; + + let approval = expect_exec_approval(&test, command).await; + let approval_id = approval.effective_approval_id(); + let amendment = approval + .proposed_execpolicy_amendment + .expect("should have a proposed execpolicy amendment"); + assert!(amendment.command.contains(&command.to_string())); + + test.codex + .submit(Op::ExecApproval { + id: approval_id, + turn_id: None, + decision: ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: amendment.clone(), + }, + }) + .await?; + wait_for_completion(&test).await; + + let call_id = "invalid-prefix-rule-again"; + let command = + "touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt"; + let event = shell_event_with_prefix_rule( + call_id, + command, + 1_000, + SandboxPermissions::RequireEscalated, + Some(vec!["touch".to_string()]), + )?; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-invalid-prefix-1"), + event, + ev_completed("resp-invalid-prefix-1"), + ]), + ) + .await; + let second_results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-invalid-prefix-1", "done"), + ev_completed("resp-invalid-prefix-2"), + ]), + ) + .await; + + submit_turn( + &test, + "invalid-prefix-rule", + approval_policy, + sandbox_policy.clone(), + ) + .await?; + + wait_for_completion_without_approval(&test).await; + + let second_output = parse_result( + &second_results + .single_request() + .function_call_output(call_id), + ); + assert_eq!(second_output.exit_code.unwrap_or(0), 0); + assert!( + second_output.stdout.is_empty(), + "unexpected stdout: {}", + second_output.stdout + ); + + Ok(()) +} + // todo(dylan) add ScenarioSpec support for rules #[tokio::test(flavor = "current_thread")] #[cfg(unix)] From 264fc444b62cbd6fe438e7bcb601a96830cd5969 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 20 Feb 2026 23:02:24 -0800 Subject: [PATCH 028/282] feat: discourage the use of the --all-features flag (#12429) ## Why Developers are frequently running low on disk space, and routine use of `--all-features` contributes to larger Cargo build caches in `target/` by compiling additional feature combinations. This change updates local workflow guidance to avoid `--all-features` by default and reserve it for cases where full feature coverage is specifically needed. ## What Changed - Updated `AGENTS.md` guidance for `codex-rs` to recommend `cargo test` / `just test` for full-suite local runs, and to call out the disk-usage cost of routine `--all-features` usage. - Updated the root `justfile` so `just fix` and `just clippy` no longer pass `--all-features` by default. - Updated `docs/install.md` to explicitly describe `cargo test --all-features` as an optional heavier-weight run (more build time and `target/` disk usage). ## Verification - Confirmed the `justfile` parses and the recipes list successfully with `just --list`. --- AGENTS.md | 2 +- docs/install.md | 4 +++- justfile | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa2a4bbaba..09c32d02f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ In the codex-rs folder where the rust code lives: Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests: 1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`. -2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite. +2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test` (or `just test` if `cargo-nextest` is installed). Avoid `--all-features` for routine local runs because it expands the build matrix and can significantly increase `target/` disk usage; use it only when you specifically need full feature coverage. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite. Before finalizing a large change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Do not re-run tests after running `fix` or `fmt`. diff --git a/docs/install.md b/docs/install.md index 16892c82fe..b7d4f0711a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -43,7 +43,9 @@ just fix -p cargo test -p codex-tui # If you have cargo-nextest installed, `just test` runs the test suite via nextest: just test -# If you specifically want the full `--all-features` matrix, use: +# Avoid `--all-features` for routine local runs because it increases build +# time and `target/` disk usage by compiling additional feature combinations. +# If you specifically want full feature coverage, use: cargo test --all-features ``` diff --git a/justfile b/justfile index 33d62d515b..27ba4fc9c3 100644 --- a/justfile +++ b/justfile @@ -28,10 +28,10 @@ fmt: cargo fmt -- --config imports_granularity=Item 2>/dev/null fix *args: - cargo clippy --fix --all-features --tests --allow-dirty "$@" + cargo clippy --fix --tests --allow-dirty "$@" clippy: - cargo clippy --all-features --tests "$@" + cargo clippy --tests "$@" install: rustup show active-toolchain @@ -41,6 +41,8 @@ install: # --no-fail-fast is important to ensure all tests are run. # # Run `cargo install cargo-nextest` if you don't have it installed. +# Prefer this for routine local runs; use explicit `cargo test --all-features` +# only when you specifically need full feature coverage. test: cargo nextest run --no-fail-fast From bb0ac5be70fcef5f418abf77955e96e39add2f30 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Fri, 20 Feb 2026 23:13:08 -0800 Subject: [PATCH 029/282] Fix compaction context reinjection and model baselines (#12252) ## Summary - move regular-turn context diff/full-context persistence into `run_turn` so pre-turn compaction runs before incoming context updates are recorded - after successful pre-turn compaction, rely on a cleared `reference_context_item` to trigger full context reinjection on the follow-up regular turn (manual `/compact` keeps replacement history summary-only and also clears the baseline) - preserve `` when full context is reinjected, and inject it *before* the rest of the full-context items - scope `reference_context_item` and `previous_model` to regular user turns only so standalone tasks (`/compact`, shell, review, undo) cannot suppress future reinjection or `` behavior - make context-diff persistence + `reference_context_item` updates explicit in the regular-turn path, with clearer docs/comments around the invariant - stop persisting local `/compact` `RolloutItem::TurnContext` snapshots (only regular turns persist `TurnContextItem` now) - simplify resume/fork previous-model/reference-baseline hydration by looking up the last surviving turn context from rollout lifecycle events, including rollback and compaction-crossing handling - remove the legacy fallback that guessed from bare `TurnContext` rollouts without lifecycle events - update compaction/remote-compaction/model-visible snapshots and compact test assertions (including remote compaction mock response shape) ## Why We were persisting incoming context items before spawning the regular turn task, which let pre-turn compaction requests accidentally include incoming context diffs without the new user message. Fixing that exposed follow-on baseline issues around `/compact`, resume/fork, and standalone tasks that could cause duplicate context injection or suppress `` instructions. This PR re-centers the invariants around regular turns: - regular turns persist model-visible context diffs/full reinjection and update the `reference_context_item` - standalone tasks do not advance those regular-turn baselines - compaction clears the baseline when replacement history may have stripped the referenced context diffs ## Follow-ups (TODOs left in code) - `TODO(ccunningham)`: fix rollback/backtracking baseline handling more comprehensively - `TODO(ccunningham)`: include pending incoming context items in pre-turn compaction threshold estimation - `TODO(ccunningham)`: inject updated personality spec alongside `` so some model-switch paths can avoid forced full reinjection - `TODO(ccunningham)`: review task turn lifecycle (`TurnStarted`/`TurnComplete`) behavior and emit task-start context diffs for task types that should have them (excluding `/compact`) ## Validation - `just fmt` - CI should cover the updated compaction/resume/model-visible snapshot expectations and rollout-hydration behavior - I did **not** rerun the full local test suite after the latest resume-lookup / rollout-persistence simplifications --- codex-rs/core/src/codex.rs | 713 ++++++++++++++---- codex-rs/core/src/compact.rs | 550 +++++++------- codex-rs/core/src/compact_remote.rs | 89 ++- codex-rs/core/src/context_manager/history.rs | 19 +- codex-rs/core/src/context_manager/updates.rs | 19 +- codex-rs/core/src/state/session.rs | 26 +- codex-rs/core/src/tasks/compact.rs | 9 +- codex-rs/core/src/tasks/mod.rs | 10 - codex-rs/core/src/tasks/undo.rs | 3 +- codex-rs/core/src/tasks/user_shell.rs | 4 + codex-rs/core/tests/common/responses.rs | 12 +- codex-rs/core/tests/suite/compact.rs | 38 +- codex-rs/core/tests/suite/compact_remote.rs | 57 +- .../core/tests/suite/compact_resume_fork.rs | 713 ++++-------------- .../core/tests/suite/model_visible_layout.rs | 8 +- codex-rs/core/tests/suite/prompt_caching.rs | 28 +- codex-rs/core/tests/suite/resume_warning.rs | 26 +- ...t__manual_compact_with_history_shapes.snap | 10 +- ...nual_compact_without_prev_user_shapes.snap | 8 +- ...mpling_model_switch_compaction_shapes.snap | 13 +- ..._compaction_including_incoming_shapes.snap | 15 +- ...n_strips_incoming_model_switch_shapes.snap | 15 +- ...te_manual_compact_with_history_shapes.snap | 16 +- ...y_reinjects_above_last_summary_shapes.snap | 27 +- ...te__remote_mid_turn_compaction_shapes.snap | 14 +- ...summary_only_reinjects_context_shapes.snap | 6 +- ..._compaction_including_incoming_shapes.snap | 9 +- ...n_strips_incoming_model_switch_shapes.snap | 14 +- ...resume_override_matches_rollout_model.snap | 7 +- ...layout_resume_with_personality_change.snap | 13 +- codex-rs/protocol/src/protocol.rs | 4 + 31 files changed, 1289 insertions(+), 1206 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 88e6fef893..90d496973a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -18,6 +18,7 @@ use crate::analytics_client::build_track_events_context; use crate::apps::render_apps_section; use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; +use crate::compact::InitialContextInjection; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; @@ -1599,7 +1600,7 @@ impl Session { self.record_conversation_items(&turn_context, &items).await; { let mut state = self.state.lock().await; - state.initial_context_seeded = true; + state.set_reference_context_item(Some(turn_context.to_turn_context_item())); } self.set_previous_model(None).await; // Ensure initial items are visible to immediate readers (e.g., tests, forks). @@ -1609,19 +1610,26 @@ impl Session { let rollout_items = resumed_history.history; let restored_tool_selection = Self::extract_mcp_tool_selection_from_rollout(&rollout_items); - let previous_model = Self::last_rollout_model_name(&rollout_items) - .map(std::string::ToString::to_string); + let (previous_regular_turn_context_item, crossed_compaction_after_turn) = + Self::last_rollout_regular_turn_context_lookup(&rollout_items); + let previous_model = + previous_regular_turn_context_item.map(|ctx| ctx.model.clone()); + let curr = turn_context.model_info.slug.as_str(); + let reference_context_item = if !crossed_compaction_after_turn { + previous_regular_turn_context_item.cloned() + } else { + // Keep the baseline empty when compaction may have stripped the referenced + // context diffs so the first resumed regular turn fully reinjects context. + None + }; { let mut state = self.state.lock().await; - state.initial_context_seeded = false; + state.set_reference_context_item(reference_context_item); } - self.set_previous_model(previous_model).await; + self.set_previous_model(previous_model.clone()).await; // If resuming, warn when the last recorded model differs from the current one. - let curr = turn_context.model_info.slug.as_str(); - if let Some(prev) = - Self::last_rollout_model_name(&rollout_items).filter(|p| *p != curr) - { + if let Some(prev) = previous_model.as_deref().filter(|p| *p != curr) { warn!("resuming session with different model: previous={prev}, current={curr}"); self.send_event( &turn_context, @@ -1661,8 +1669,10 @@ impl Session { InitialHistory::Forked(rollout_items) => { let restored_tool_selection = Self::extract_mcp_tool_selection_from_rollout(&rollout_items); - let previous_model = Self::last_rollout_model_name(&rollout_items) - .map(std::string::ToString::to_string); + let (previous_regular_turn_context_item, _) = + Self::last_rollout_regular_turn_context_lookup(&rollout_items); + let previous_model = + previous_regular_turn_context_item.map(|ctx| ctx.model.clone()); self.set_previous_model(previous_model).await; // Always add response items to conversation history @@ -1695,7 +1705,7 @@ impl Session { .await; { let mut state = self.state.lock().await; - state.initial_context_seeded = true; + state.set_reference_context_item(Some(turn_context.to_turn_context_item())); } // Forked threads should remain file-backed immediately after startup. @@ -1707,14 +1717,148 @@ impl Session { } } - fn last_rollout_model_name(rollout_items: &[RolloutItem]) -> Option<&str> { - rollout_items.iter().rev().find_map(|it| { - if let RolloutItem::TurnContext(ctx) = it { - Some(ctx.model.as_str()) - } else { - None + /// Returns `(last_turn_context_item, crossed_compaction_after_turn)` from the + /// rollback-adjusted rollout view. + /// + /// This relies on the invariant that only regular turns persist `TurnContextItem`. + /// `ThreadRolledBack` markers are applied so resume/fork uses the post-rollback history view. + /// + /// Returns `(None, false)` when no persisted `TurnContextItem` can be found. + /// + /// Older/minimal rollouts may only contain `RolloutItem::TurnContext` entries without turn + /// lifecycle events. In that case we fall back to the last `TurnContextItem` (plus whether a + /// later `Compacted` item appears in rollout order). + // TODO(ccunningham): Simplify this lookup by sharing rollout traversal/rollback application + // with `reconstruct_history_from_rollout` so resume/fork baseline hydration does not need a + // second bespoke rollout scan. + fn last_rollout_regular_turn_context_lookup( + rollout_items: &[RolloutItem], + ) -> (Option<&TurnContextItem>, bool) { + // Reverse scan over rollout items. `ThreadRolledBack(num_turns)` is naturally handled by + // skipping the next `num_turns` completed turn spans we encounter while walking backward. + // + // "Active turn" here means: we have seen `TurnComplete`/`TurnAborted` and are currently + // scanning backward through that completed turn until its matching `TurnStarted`. + let mut turns_to_skip_due_to_rollback = 0usize; + let mut saw_surviving_compaction_after_candidate = false; + let mut saw_turn_lifecycle_event = false; + let mut active_turn_id: Option<&str> = None; + let mut active_turn_saw_user_message = false; + let mut active_turn_context: Option<&TurnContextItem> = None; + let mut active_turn_contains_compaction = false; + + for item in rollout_items.iter().rev() { + match item { + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => { + // Rollbacks count completed turns, not `TurnContextItem`s. We must continue + // ignoring all items inside each skipped turn until we reach its + // corresponding `TurnStarted`. + let num_turns = usize::try_from(rollback.num_turns).unwrap_or(usize::MAX); + turns_to_skip_due_to_rollback = + turns_to_skip_due_to_rollback.saturating_add(num_turns); + } + RolloutItem::EventMsg(EventMsg::TurnComplete(event)) => { + saw_turn_lifecycle_event = true; + // Enter the reverse "turn span" for this completed turn. + active_turn_id = Some(event.turn_id.as_str()); + active_turn_saw_user_message = false; + active_turn_context = None; + active_turn_contains_compaction = false; + } + RolloutItem::EventMsg(EventMsg::TurnAborted(event)) => { + saw_turn_lifecycle_event = true; + // Same reverse-turn handling as `TurnComplete`. Some aborted turns may not + // have a turn id; in that case we cannot match `TurnContextItem`s to them. + active_turn_id = event.turn_id.as_deref(); + active_turn_saw_user_message = false; + active_turn_context = None; + active_turn_contains_compaction = false; + } + RolloutItem::EventMsg(EventMsg::UserMessage(_)) => { + if active_turn_id.is_some() { + active_turn_saw_user_message = true; + } + } + RolloutItem::EventMsg(EventMsg::TurnStarted(event)) => { + saw_turn_lifecycle_event = true; + if active_turn_id == Some(event.turn_id.as_str()) { + let active_turn_is_rolled_back = + active_turn_saw_user_message && turns_to_skip_due_to_rollback > 0; + if active_turn_is_rolled_back { + // `ThreadRolledBack(num_turns)` counts user turns, so only consume a + // skip once we've confirmed this reverse-scanned turn span contains a + // user message. Standalone task turns must not consume rollback skips. + turns_to_skip_due_to_rollback -= 1; + } + if !active_turn_is_rolled_back { + if let Some(context_item) = active_turn_context { + return ( + Some(context_item), + saw_surviving_compaction_after_candidate, + ); + } + // No `TurnContextItem` in this surviving turn; keep scanning older + // turns, but remember if this turn compacted so the eventual + // candidate reports "compaction happened after it". + if active_turn_contains_compaction { + saw_surviving_compaction_after_candidate = true; + } + } + active_turn_id = None; + active_turn_saw_user_message = false; + active_turn_context = None; + active_turn_contains_compaction = false; + } + } + RolloutItem::TurnContext(ctx) => { + // Capture the latest turn context seen in this reverse-scanned turn span. If + // the turn later proves to be rolled back, we discard it when we hit the + // matching `TurnStarted`. Older rollouts may have lifecycle events but omit + // `TurnContextItem.turn_id`; accept those as belonging to the active turn + // span for resume/fork hydration. + if let Some(active_id) = active_turn_id + && ctx + .turn_id + .as_deref() + .is_none_or(|turn_id| turn_id == active_id) + { + // Reverse scan sees the latest `TurnContextItem` for the turn first. + active_turn_context.get_or_insert(ctx); + } + } + RolloutItem::Compacted(_) => { + if active_turn_id.is_some() { + // Compaction inside the currently scanned turn is only "after" the + // eventual candidate if this turn has no `TurnContextItem` and we keep + // scanning into older turns. + active_turn_contains_compaction = true; + } else { + saw_surviving_compaction_after_candidate = true; + } + } + _ => {} } - }) + } + + // Legacy/minimal rollouts may only persist `TurnContextItem`/`Compacted` without turn + // lifecycle events. Fall back to the last `TurnContextItem` in rollout order so + // resume/fork can still hydrate `previous_model` and detect compaction-after-baseline. + if !saw_turn_lifecycle_event { + let mut saw_compaction_after_last_turn_context = false; + for item in rollout_items.iter().rev() { + match item { + RolloutItem::Compacted(_) => { + saw_compaction_after_last_turn_context = true; + } + RolloutItem::TurnContext(ctx) => { + return (Some(ctx), saw_compaction_after_last_turn_context); + } + _ => {} + } + } + } + + (None, false) } fn last_token_info_from_rollout(rollout_items: &[RolloutItem]) -> Option { @@ -2033,33 +2177,21 @@ impl Session { .await } - pub(crate) fn is_model_switch_developer_message(item: &ResponseItem) -> bool { - let ResponseItem::Message { role, content, .. } = item else { - return false; - }; - role == "developer" - && content.iter().any(|content_item| { - matches!( - content_item, - ContentItem::InputText { text } if text.starts_with("") - ) - }) - } fn build_settings_update_items( &self, - previous_context: Option<&TurnContextItem>, - resumed_model: Option<&str>, + reference_context_item: Option<&TurnContextItem>, + previous_user_turn_model: Option<&str>, current_context: &TurnContext, ) -> Vec { // TODO: Make context updates a pure diff of persisted previous/current TurnContextItem // state so replay/backtracking is deterministic. Runtime inputs that affect model-visible - // context (shell, exec policy, feature gates, resumed model bridge) should be persisted + // context (shell, exec policy, feature gates, previous-model bridge) should be persisted // state or explicit non-state replay events. let shell = self.user_shell(); let exec_policy = self.services.exec_policy.current(); crate::context_manager::updates::build_settings_update_items( - previous_context, - resumed_model, + reference_context_item, + previous_user_turn_model, current_context, shell.as_ref(), exec_policy.as_ref(), @@ -2465,15 +2597,6 @@ impl Session { history.raw_items().to_vec() } - pub(crate) async fn process_compacted_history( - &self, - turn_context: &TurnContext, - compacted_history: Vec, - ) -> Vec { - let initial_context = self.build_initial_context(turn_context).await; - compact::process_compacted_history(compacted_history, &initial_context) - } - /// Append ResponseItems to the in-memory conversation history only. pub(crate) async fn record_into_history( &self, @@ -2542,24 +2665,13 @@ impl Session { true } - pub(crate) async fn replace_history(&self, items: Vec) { + pub(crate) async fn replace_history( + &self, + items: Vec, + reference_context_item: Option, + ) { let mut state = self.state.lock().await; - state.replace_history(items); - } - - pub(crate) async fn seed_initial_context_if_needed(&self, turn_context: &TurnContext) { - { - let mut state = self.state.lock().await; - if state.initial_context_seeded { - return; - } - state.initial_context_seeded = true; - } - - let initial_context = self.build_initial_context(turn_context).await; - self.record_conversation_items(turn_context, &initial_context) - .await; - self.flush_rollout().await; + state.replace_history(items, reference_context_item); } async fn persist_rollout_response_items(&self, items: &[ResponseItem]) { @@ -2693,14 +2805,61 @@ impl Session { state.clone_history() } - pub(crate) async fn previous_context_item(&self) -> Option { + pub(crate) async fn reference_context_item(&self) -> Option { let state = self.state.lock().await; - state.previous_context_item() + state.reference_context_item() } - pub(crate) async fn set_previous_context_item(&self, item: Option) { + /// Persist the latest turn context snapshot and emit any required model-visible context updates. + /// + /// When the reference snapshot is missing, this injects full initial context. Otherwise, it + /// emits only settings diff items. + /// + /// If full context is injected and a model switch occurred, this prepends the + /// `` developer message so model-specific instructions are not lost. + /// + /// Invariant: this is the only runtime path that writes a non-`None` + /// `reference_context_item`. Non-regular tasks intentionally do not update that + /// baseline; `reference_context_item` tracks the latest regular model turn. + pub(crate) async fn record_context_updates_and_set_reference_context_item( + &self, + turn_context: &TurnContext, + previous_user_turn_model: Option<&str>, + ) { + let reference_context_item = self.reference_context_item().await; + let should_inject_full_context = reference_context_item.is_none(); + let context_items = if should_inject_full_context { + let mut initial_context = self.build_initial_context(turn_context).await; + // Full reinjection bypasses the settings-diff path, so add the model-switch + // instruction explicitly when needed. Keep it before the rest of full context so + // model-specific guidance is read first. + if let Some(model_switch_item) = + crate::context_manager::updates::build_model_instructions_update_item( + previous_user_turn_model, + turn_context, + ) + { + // TODO(ccunningham): When a model switch changes the effective personality + // instructions, inject the updated personality spec alongside + // here so resume/model-switch paths can avoid forcing full reinjection. + initial_context.insert(0, model_switch_item); + } + initial_context + } else { + // Steady-state path: append only context diffs to minimize token overhead. + self.build_settings_update_items( + reference_context_item.as_ref(), + previous_user_turn_model, + turn_context, + ) + }; + if !context_items.is_empty() { + self.record_conversation_items(turn_context, &context_items) + .await; + } + let mut state = self.state.lock().await; - state.set_previous_context_item(item); + state.set_reference_context_item(Some(turn_context.to_turn_context_item())); } pub(crate) async fn update_token_usage_info( @@ -3168,11 +3327,6 @@ impl Session { } async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiver) { - // Seed with context in case there is an OverrideTurnContext first. - let initial_context = sess.new_default_turn().await; - sess.set_previous_context_item(Some(initial_context.to_turn_context_item())) - .await; - // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); @@ -3486,26 +3640,11 @@ mod handlers { // Attempt to inject input into current task. if let Err(SteerInputError::NoActiveTurn(items)) = sess.steer_input(items, None).await { - sess.seed_initial_context_if_needed(¤t_context).await; - let previous_model = sess.previous_model().await; - let previous_context_item = sess.previous_context_item().await; - let update_items = sess.build_settings_update_items( - previous_context_item.as_ref(), - previous_model.as_deref(), - ¤t_context, - ); - if !update_items.is_empty() { - sess.record_conversation_items(¤t_context, &update_items) - .await; - } - sess.refresh_mcp_servers_if_requested(¤t_context) .await; let regular_task = sess.take_startup_regular_task().await.unwrap_or_default(); sess.spawn_task(Arc::clone(¤t_context), items, regular_task) .await; - sess.set_previous_context_item(Some(current_context.to_turn_context_item())) - .await; } } @@ -3534,8 +3673,6 @@ mod handlers { UserShellCommandTask::new(command), ) .await; - sess.set_previous_context_item(Some(turn_context.to_turn_context_item())) - .await; } pub async fn resolve_elicitation( @@ -3965,15 +4102,20 @@ mod handlers { } let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; - sess.set_previous_model(Some(turn_context.model_info.slug.clone())) - .await; let mut history = sess.clone_history().await; + // TODO(ccunningham): Fix rollback/backtracking baseline handling. + // We clear `reference_context_item` here, but should restore the + // post-rollback baseline from the surviving history/rollout instead. + // Truncating history should also invalidate/recompute `previous_model` + // so the next regular turn replays any dropped model-switch + // instructions. history.drop_last_n_user_turns(num_turns); // Replace with the raw items. We don't want to replace with a normalized // version of the history. - sess.replace_history(history.raw_items().to_vec()).await; + sess.replace_history(history.raw_items().to_vec(), None) + .await; sess.recompute_token_usage(turn_context.as_ref()).await; sess.send_event_raw_flushed(Event { @@ -4247,6 +4389,9 @@ async fn spawn_review_thread( }]; let tc = Arc::new(review_turn_context); tc.turn_metadata_state.spawn_git_enrichment_task(); + // TODO(ccunningham): Review turns currently rely on `spawn_task` for TurnComplete but do not + // emit a parent TurnStarted. Consider giving review a full parent turn lifecycle + // (TurnStarted + TurnComplete) for consistency with other standalone tasks. sess.spawn_task(tc.clone(), input, ReviewTask::new()).await; // Announce entering review mode so UIs can switch modes. @@ -4346,6 +4491,10 @@ pub(crate) async fn run_turn( collaboration_mode_kind: turn_context.collaboration_mode.mode, }); sess.send_event(&turn_context, event).await; + // TODO(ccunningham): Pre-turn compaction runs before context updates and the + // new user message are recorded. Estimate pending incoming items (context + // diffs/full reinjection + user input) and trigger compaction preemptively + // when they would push the thread over the compaction threshold. if run_pre_sampling_compact(&sess, &turn_context) .await .is_err() @@ -4354,6 +4503,13 @@ pub(crate) async fn run_turn( return None; } + let previous_model = sess.previous_model().await; + sess.record_context_updates_and_set_reference_context_item( + turn_context.as_ref(), + previous_model.as_deref(), + ) + .await; + let skills_outcome = Some( sess.services .skills_manager @@ -4465,6 +4621,11 @@ pub(crate) async fn run_turn( let response_item: ResponseItem = initial_input_for_turn.clone().into(); sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item) .await; + // Track the previous-model baseline from the regular user-turn path only so + // standalone tasks (compact/shell/review/undo) cannot suppress future + // `` injections. + sess.set_previous_model(Some(turn_context.model_info.slug.clone())) + .await; if !skill_items.is_empty() { sess.record_conversation_items(&turn_context, &skill_items) @@ -4568,7 +4729,14 @@ pub(crate) async fn run_turn( // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached && needs_follow_up { - if run_auto_compact(&sess, &turn_context).await.is_err() { + if run_auto_compact( + &sess, + &turn_context, + InitialContextInjection::BeforeLastUserMessage, + ) + .await + .is_err() + { return None; } continue; @@ -4688,7 +4856,7 @@ async fn run_pre_sampling_compact( .unwrap_or(i64::MAX); // Compact if the total usage tokens are greater than the auto compact limit if total_usage_tokens >= auto_compact_limit { - run_auto_compact(sess, turn_context).await?; + run_auto_compact(sess, turn_context, InitialContextInjection::DoNotInject).await?; } Ok(()) } @@ -4696,47 +4864,67 @@ async fn run_pre_sampling_compact( /// Runs pre-sampling compaction against the previous model when switching to a smaller /// context-window model. /// -/// Returns `Ok(())` when compaction either completed successfully or was skipped because the -/// model/context-window preconditions were not met. Returns `Err(_)` only when compaction was -/// attempted and failed. +/// Returns `Ok(true)` when compaction ran successfully, `Ok(false)` when compaction was skipped +/// because the model/context-window preconditions were not met, and `Err(_)` only when compaction +/// was attempted and failed. async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, total_usage_tokens: i64, -) -> CodexResult<()> { +) -> CodexResult { let Some(previous_model) = sess.previous_model().await else { - return Ok(()); + return Ok(false); }; - let previous_turn_context = Arc::new( + let previous_model_turn_context = Arc::new( turn_context .with_model(previous_model, &sess.services.models_manager) .await, ); - let Some(old_context_window) = previous_turn_context.model_context_window() else { - return Ok(()); + let Some(old_context_window) = previous_model_turn_context.model_context_window() else { + return Ok(false); }; let Some(new_context_window) = turn_context.model_context_window() else { - return Ok(()); + return Ok(false); }; let new_auto_compact_limit = turn_context .model_info .auto_compact_token_limit() .unwrap_or(i64::MAX); let should_run = total_usage_tokens > new_auto_compact_limit - && previous_turn_context.model_info.slug != turn_context.model_info.slug + && previous_model_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; if should_run { - run_auto_compact(sess, &previous_turn_context).await?; + run_auto_compact( + sess, + &previous_model_turn_context, + InitialContextInjection::DoNotInject, + ) + .await?; + return Ok(true); } - Ok(()) + Ok(false) } -async fn run_auto_compact(sess: &Arc, turn_context: &Arc) -> CodexResult<()> { +async fn run_auto_compact( + sess: &Arc, + turn_context: &Arc, + initial_context_injection: InitialContextInjection, +) -> CodexResult<()> { if should_use_remote_compact_task(&turn_context.provider) { - run_inline_remote_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?; + run_inline_remote_auto_compact_task( + Arc::clone(sess), + Arc::clone(turn_context), + initial_context_injection, + ) + .await?; } else { - run_inline_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?; + run_inline_auto_compact_task( + Arc::clone(sess), + Arc::clone(turn_context), + initial_context_injection, + ) + .await?; } Ok(()) } @@ -5525,6 +5713,9 @@ async fn try_run_sampling_request( prompt: &Prompt, cancellation_token: CancellationToken, ) -> CodexResult { + // Persist one TurnContext marker per sampling request (not just per user turn) so rollout + // analysis can reconstruct API-turn boundaries. `run_turn` persists model-visible context + // diffs/full reinjection earlier in the same regular turn before reaching this path. let rollout_item = RolloutItem::TurnContext(turn_context.to_turn_context_item()); feedback_tags!( @@ -6458,7 +6649,7 @@ mod tests { async fn record_initial_history_resumed_hydrates_previous_model() { let (session, turn_context) = make_session_and_context().await; let previous_model = "previous-rollout-model"; - let rollout_items = vec![RolloutItem::TurnContext(TurnContextItem { + let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), cwd: turn_context.cwd.clone(), approval_policy: turn_context.approval_policy, @@ -6473,7 +6664,8 @@ mod tests { developer_instructions: None, final_output_json_schema: None, truncation_policy: Some(turn_context.truncation_policy.into()), - })]; + }; + let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)]; session .record_initial_history(InitialHistory::Resumed(ResumedHistory { @@ -6490,7 +6682,182 @@ mod tests { } #[tokio::test] - async fn resumed_history_seeds_initial_context_on_first_turn_only() { + async fn record_initial_history_resumed_hydrates_previous_model_from_lifecycle_turn_with_missing_turn_context_id() + { + let (session, turn_context) = make_session_and_context().await; + let previous_model = "previous-rollout-model"; + let mut previous_context_item = TurnContextItem { + turn_id: Some(turn_context.sub_id.clone()), + cwd: turn_context.cwd.clone(), + approval_policy: turn_context.approval_policy, + sandbox_policy: turn_context.sandbox_policy.clone(), + network: None, + model: previous_model.to_string(), + personality: turn_context.personality, + collaboration_mode: Some(turn_context.collaboration_mode.clone()), + effort: turn_context.reasoning_effort, + summary: turn_context.reasoning_summary, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: Some(turn_context.truncation_policy.into()), + }; + let turn_id = previous_context_item + .turn_id + .clone() + .expect("turn context should have turn_id"); + previous_context_item.turn_id = None; + + let rollout_items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage( + codex_protocol::protocol::UserMessageEvent { + message: "seed".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + )), + RolloutItem::TurnContext(previous_context_item), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id, + last_agent_message: None, + }, + )), + ]; + + session + .record_initial_history(InitialHistory::Resumed(ResumedHistory { + conversation_id: ThreadId::default(), + history: rollout_items, + rollout_path: PathBuf::from("/tmp/resume.jsonl"), + })) + .await; + + assert_eq!( + session.previous_model().await, + Some(previous_model.to_string()) + ); + } + + #[tokio::test] + async fn record_initial_history_resumed_rollback_skips_only_user_turns() { + let (session, turn_context) = make_session_and_context().await; + let previous_context_item = turn_context.to_turn_context_item(); + let user_turn_id = previous_context_item + .turn_id + .clone() + .expect("turn context should have turn_id"); + let standalone_turn_id = "standalone-task-turn".to_string(); + let rollout_items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: user_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage( + codex_protocol::protocol::UserMessageEvent { + message: "seed".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + )), + RolloutItem::TurnContext(previous_context_item), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id: user_turn_id, + last_agent_message: None, + }, + )), + // Standalone task turn (no UserMessage) should not consume rollback skips. + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: standalone_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id: standalone_turn_id, + last_agent_message: None, + }, + )), + RolloutItem::EventMsg(EventMsg::ThreadRolledBack( + codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + )), + ]; + + session + .record_initial_history(InitialHistory::Resumed(ResumedHistory { + conversation_id: ThreadId::default(), + history: rollout_items, + rollout_path: PathBuf::from("/tmp/resume.jsonl"), + })) + .await; + + assert_eq!(session.previous_model().await, None); + assert!(session.reference_context_item().await.is_none()); + } + + #[tokio::test] + async fn record_initial_history_resumed_seeds_reference_context_item_without_compaction() { + let (session, turn_context) = make_session_and_context().await; + let previous_context_item = turn_context.to_turn_context_item(); + let rollout_items = vec![RolloutItem::TurnContext(previous_context_item.clone())]; + + session + .record_initial_history(InitialHistory::Resumed(ResumedHistory { + conversation_id: ThreadId::default(), + history: rollout_items, + rollout_path: PathBuf::from("/tmp/resume.jsonl"), + })) + .await; + + assert_eq!( + serde_json::to_value(session.reference_context_item().await) + .expect("serialize seeded reference context item"), + serde_json::to_value(Some(previous_context_item)) + .expect("serialize expected reference context item") + ); + } + + #[tokio::test] + async fn record_initial_history_resumed_does_not_seed_reference_context_item_after_compaction() + { + let (session, turn_context) = make_session_and_context().await; + let previous_context_item = turn_context.to_turn_context_item(); + let rollout_items = vec![ + RolloutItem::TurnContext(previous_context_item), + RolloutItem::Compacted(CompactedItem { + message: String::new(), + replacement_history: Some(Vec::new()), + }), + ]; + + session + .record_initial_history(InitialHistory::Resumed(ResumedHistory { + conversation_id: ThreadId::default(), + history: rollout_items, + rollout_path: PathBuf::from("/tmp/resume.jsonl"), + })) + .await; + + assert!(session.reference_context_item().await.is_none()); + } + + #[tokio::test] + async fn resumed_history_injects_initial_context_on_first_context_update_only() { let (session, turn_context) = make_session_and_context().await; let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; @@ -6505,12 +6872,16 @@ mod tests { let history_before_seed = session.state.lock().await.clone_history(); assert_eq!(expected, history_before_seed.raw_items()); - session.seed_initial_context_if_needed(&turn_context).await; + session + .record_context_updates_and_set_reference_context_item(&turn_context, None) + .await; expected.extend(session.build_initial_context(&turn_context).await); let history_after_seed = session.clone_history().await; assert_eq!(expected, history_after_seed.raw_items()); - session.seed_initial_context_if_needed(&turn_context).await; + session + .record_context_updates_and_set_reference_context_item(&turn_context, None) + .await; let history_after_second_seed = session.clone_history().await; assert_eq!(expected, history_after_second_seed.raw_items()); } @@ -6677,7 +7048,7 @@ mod tests { async fn record_initial_history_forked_hydrates_previous_model() { let (session, turn_context) = make_session_and_context().await; let previous_model = "forked-rollout-model"; - let rollout_items = vec![RolloutItem::TurnContext(TurnContextItem { + let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), cwd: turn_context.cwd.clone(), approval_policy: turn_context.approval_policy, @@ -6692,7 +7063,8 @@ mod tests { developer_instructions: None, final_output_json_schema: None, truncation_policy: Some(turn_context.truncation_policy.into()), - })]; + }; + let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)]; session .record_initial_history(InitialHistory::Forked(rollout_items)) @@ -6755,6 +7127,8 @@ mod tests { }, ]; sess.record_into_history(&turn_2, tc.as_ref()).await; + sess.set_previous_model(Some("previous-regular-model".to_string())) + .await; handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; @@ -6769,7 +7143,7 @@ mod tests { assert_eq!(expected, history.raw_items()); assert_eq!( sess.previous_model().await, - Some(tc.model_info.slug.clone()) + Some("previous-regular-model".to_string()) ); } @@ -7426,8 +7800,7 @@ mod tests { session_configuration.session_source.clone(), ); - let mut state = SessionState::new(session_configuration.clone()); - mark_state_initial_context_seeded(&mut state); + let state = SessionState::new(session_configuration.clone()); let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let network_approval = Arc::new(NetworkApprovalService::default()); @@ -7583,8 +7956,7 @@ mod tests { session_configuration.session_source.clone(), ); - let mut state = SessionState::new(session_configuration.clone()); - mark_state_initial_context_seeded(&mut state); + let state = SessionState::new(session_configuration.clone()); let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let network_approval = Arc::new(NetworkApprovalService::default()); @@ -7671,10 +8043,6 @@ mod tests { (session, turn_context, rx_event) } - fn mark_state_initial_context_seeded(state: &mut SessionState) { - state.initial_context_seeded = true; - } - #[tokio::test] async fn refresh_mcp_servers_is_deferred_until_next_turn() { let (session, turn_context) = make_session_and_context().await; @@ -7746,7 +8114,7 @@ mod tests { } #[tokio::test] - async fn spawn_task_hydrates_previous_model() { + async fn spawn_task_does_not_update_previous_model_for_non_run_turn_tasks() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; sess.set_previous_model(None).await; let input = vec![UserInput::Text { @@ -7765,10 +8133,7 @@ mod tests { .await; sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - assert_eq!( - sess.previous_model().await, - Some(tc.model_info.slug.clone()) - ); + assert_eq!(sess.previous_model().await, None); } #[tokio::test] @@ -7806,9 +8171,9 @@ mod tests { .expect("rebuild config layer stack with network requirements"); current_context.config = Arc::new(config); - let previous_context_item = previous_context.to_turn_context_item(); + let reference_context_item = previous_context.to_turn_context_item(); let update_items = session.build_settings_update_items( - Some(&previous_context_item), + Some(&reference_context_item), None, ¤t_context, ); @@ -7830,6 +8195,92 @@ mod tests { assert!(environment_update.contains("blocked.example.com")); } + #[tokio::test] + async fn record_context_updates_and_set_reference_context_item_injects_full_context_when_baseline_missing() + { + let (session, turn_context) = make_session_and_context().await; + session + .record_context_updates_and_set_reference_context_item(&turn_context, None) + .await; + let history = session.clone_history().await; + let initial_context = session.build_initial_context(&turn_context).await; + assert_eq!(history.raw_items().to_vec(), initial_context); + + let current_context = session.reference_context_item().await; + assert_eq!( + serde_json::to_value(current_context).expect("serialize current context item"), + serde_json::to_value(Some(turn_context.to_turn_context_item())) + .expect("serialize expected context item") + ); + } + + #[tokio::test] + async fn record_context_updates_and_set_reference_context_item_reinjects_full_context_after_clear() + { + let (session, turn_context) = make_session_and_context().await; + let compacted_summary = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{}\nsummary", crate::compact::SUMMARY_PREFIX), + }], + end_turn: None, + phase: None, + }; + session + .record_into_history(std::slice::from_ref(&compacted_summary), &turn_context) + .await; + session + .record_context_updates_and_set_reference_context_item(&turn_context, None) + .await; + { + let mut state = session.state.lock().await; + state.set_reference_context_item(None); + } + session + .replace_history(vec![compacted_summary.clone()], None) + .await; + + session + .record_context_updates_and_set_reference_context_item(&turn_context, None) + .await; + + let history = session.clone_history().await; + let mut expected_history = vec![compacted_summary]; + expected_history.extend(session.build_initial_context(&turn_context).await); + assert_eq!(history.raw_items().to_vec(), expected_history); + } + + #[tokio::test] + async fn run_user_shell_command_does_not_set_reference_context_item() { + let (session, _turn_context, rx) = make_session_and_context_with_rx().await; + { + let mut state = session.state.lock().await; + state.set_reference_context_item(None); + } + + handlers::run_user_shell_command(&session, "sub-id".to_string(), "echo shell".to_string()) + .await; + + let deadline = StdDuration::from_secs(5); + let start = std::time::Instant::now(); + loop { + let remaining = deadline.saturating_sub(start.elapsed()); + let evt = tokio::time::timeout(remaining, rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + if matches!(evt.msg, EventMsg::TurnComplete(_)) { + break; + } + } + + assert!( + session.reference_context_item().await.is_none(), + "standalone shell tasks should not mutate previous context" + ); + } + #[derive(Clone, Copy)] struct NeverEndingTask { kind: TaskKind, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4065251425..43ee08fd4f 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -32,10 +32,37 @@ pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md"); const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; +/// Controls whether compaction replacement history must include initial context. +/// +/// Pre-turn/manual compaction variants use `DoNotInject`: they replace history with a summary and +/// clear `reference_context_item`, so the next regular turn will fully reinject initial context +/// after compaction. +/// +/// Mid-turn compaction must use `BeforeLastUserMessage` because the model is trained to see the +/// compaction summary as the last item in history after mid-turn compaction; we therefore inject +/// initial context into the replacement history just above the last real user message. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InitialContextInjection { + BeforeLastUserMessage, + DoNotInject, +} + pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bool { provider.is_openai() } +fn is_model_switch_developer_message(item: &ResponseItem) -> bool { + match item { + ResponseItem::Message { role, content, .. } if role == "developer" => { + matches!( + content.as_slice(), + [ContentItem::InputText { text }] if text.starts_with("\n") + ) + } + _ => false, + } +} + pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( history: &mut ContextManager, ) -> Option { @@ -49,7 +76,7 @@ pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( .rev() .find_map(|(i, item)| { let is_trailing = last_user_turn_boundary_index.is_none_or(|boundary| i > boundary); - if is_trailing && Session::is_model_switch_developer_message(item) { + if is_trailing && is_model_switch_developer_message(item) { Some(i) } else { None @@ -64,6 +91,7 @@ pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, + initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { let prompt = turn_context.compact_prompt().to_string(); let input = vec![UserInput::Text { @@ -72,7 +100,7 @@ pub(crate) async fn run_inline_auto_compact_task( text_elements: Vec::new(), }]; - run_compact_task_inner(sess, turn_context, input).await?; + run_compact_task_inner(sess, turn_context, input, initial_context_injection).await?; Ok(()) } @@ -87,13 +115,20 @@ pub(crate) async fn run_compact_task( collaboration_mode_kind: turn_context.collaboration_mode.mode, }); sess.send_event(&turn_context, start_event).await; - run_compact_task_inner(sess.clone(), turn_context, input).await + run_compact_task_inner( + sess.clone(), + turn_context, + input, + InitialContextInjection::DoNotInject, + ) + .await } async fn run_compact_task_inner( sess: Arc, turn_context: Arc, input: Vec, + initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) @@ -118,9 +153,6 @@ async fn run_compact_task_inner( // Reuse one client session so turn-scoped state (sticky routing, websocket append tracking) // survives retries within this compact turn. - let rollout_item = RolloutItem::TurnContext(turn_context.to_turn_context_item()); - sess.persist_rollout_items(&[rollout_item]).await; - loop { // Clone is required because of the loop let turn_input = history @@ -202,8 +234,16 @@ async fn run_compact_task_inner( let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}"); let user_messages = collect_user_messages(history_items); - let initial_context = sess.build_initial_context(turn_context.as_ref()).await; - let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text); + let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text); + + if matches!( + initial_context_injection, + InitialContextInjection::BeforeLastUserMessage + ) { + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + new_history = + insert_initial_context_before_last_real_user_or_summary(new_history, initial_context); + } // Reattach the stripped model-switch update only after successful compaction so the model // still sees the switch instructions on the next real sampling request. if let Some(model_switch_item) = stripped_model_switch_item { @@ -215,12 +255,17 @@ async fn run_compact_task_inner( .cloned() .collect(); new_history.extend(ghost_snapshots); - sess.replace_history(new_history).await; + let reference_context_item = match initial_context_injection { + InitialContextInjection::DoNotInject => None, + InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()), + }; + sess.replace_history(new_history.clone(), reference_context_item) + .await; sess.recompute_token_usage(&turn_context).await; let rollout_item = RolloutItem::Compacted(CompactedItem { message: summary_text.clone(), - replacement_history: None, + replacement_history: Some(new_history), }); sess.persist_rollout_items(&[rollout_item]).await; @@ -272,24 +317,50 @@ pub(crate) fn is_summary_message(message: &str) -> bool { message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str()) } -pub(crate) fn process_compacted_history( +/// Inserts canonical initial context into compacted replacement history at the +/// model-expected boundary. +/// +/// Placement rules: +/// - Prefer immediately before the last real user message. +/// - If no real user messages remain, insert before the compaction summary so +/// the summary stays last. +/// - If there are no user messages, insert before the last compaction item so +/// that item remains last (remote compaction may return only compaction items). +/// - If there are no user messages or compaction items, append the context. +pub(crate) fn insert_initial_context_before_last_real_user_or_summary( mut compacted_history: Vec, - initial_context: &[ResponseItem], + initial_context: Vec, ) -> Vec { - compacted_history.retain(should_keep_compacted_history_item); - - let initial_context = initial_context.to_vec(); + let mut last_user_or_summary_index = None; + let mut last_real_user_index = None; + for (i, item) in compacted_history.iter().enumerate().rev() { + let Some(TurnItem::UserMessage(user)) = crate::event_mapping::parse_turn_item(item) else { + continue; + }; + // Compaction summaries are encoded as user messages, so track both: + // the last real user message (preferred insertion point) and the last + // user-message-like item (fallback summary insertion point). + last_user_or_summary_index.get_or_insert(i); + if !is_summary_message(&user.message()) { + last_real_user_index = Some(i); + break; + } + } + let last_compaction_index = compacted_history + .iter() + .enumerate() + .rev() + .find_map(|(i, item)| matches!(item, ResponseItem::Compaction { .. }).then_some(i)); + let insertion_index = last_real_user_index + .or(last_user_or_summary_index) + .or(last_compaction_index); // Re-inject canonical context from the current session since we stripped it - // from the pre-compaction history. Keep it right before the last user - // message so older user messages remain earlier in the transcript. - if let Some(last_user_index) = compacted_history.iter().rposition(|item| { - matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(_)) - ) - }) { - compacted_history.splice(last_user_index..last_user_index, initial_context); + // from the pre-compaction history. Prefer placing it before the last real + // user message; if there is no real user message left, place it before the + // summary or compaction item so the compaction item remains last. + if let Some(insertion_index) = insertion_index { + compacted_history.splice(insertion_index..insertion_index, initial_context); } else { compacted_history.extend(initial_context); } @@ -297,30 +368,6 @@ pub(crate) fn process_compacted_history( compacted_history } -/// Returns whether an item from remote compaction output should be preserved. -/// -/// Called while processing the model-provided compacted transcript, before we -/// append fresh canonical context from the current session. -/// -/// We drop: -/// - `developer` messages because remote output can include stale/duplicated -/// instruction content. -/// - non-user-content `user` messages (session prefix/instruction wrappers), -/// keeping only real user messages as parsed by `parse_turn_item`. -/// -/// This intentionally keeps `user`-role warnings and compaction-generated -/// summary messages because they parse as `TurnItem::UserMessage`. -fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { - match item { - ResponseItem::Message { role, .. } if role == "developer" => false, - ResponseItem::Message { role, .. } if role == "user" => matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(_)) - ), - _ => true, - } -} - pub(crate) fn build_compacted_history( initial_context: Vec, user_messages: &[String], @@ -442,6 +489,21 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + async fn process_compacted_history_with_test_session( + compacted_history: Vec, + ) -> (Vec, Vec) { + let (session, turn_context) = crate::codex::make_session_and_context().await; + let initial_context = session.build_initial_context(&turn_context).await; + let refreshed = crate::compact_remote::process_compacted_history( + &session, + &turn_context, + compacted_history, + InitialContextInjection::BeforeLastUserMessage, + ) + .await; + (refreshed, initial_context) + } + #[test] fn content_items_to_text_joins_non_empty_segments() { let items = vec![ @@ -514,7 +576,7 @@ mod tests { history .raw_items() .iter() - .all(|item| !Session::is_model_switch_developer_message(item)) + .all(|item| !is_model_switch_developer_message(item)) ); } @@ -569,7 +631,7 @@ mod tests { history .raw_items() .iter() - .any(Session::is_model_switch_developer_message) + .any(is_model_switch_developer_message) ); } @@ -708,8 +770,8 @@ do things assert_eq!(summary, summary_text); } - #[test] - fn process_compacted_history_replaces_developer_messages() { + #[tokio::test] + async fn process_compacted_history_replaces_developer_messages() { let compacted_history = vec![ ResponseItem::Message { id: None, @@ -739,88 +801,22 @@ do things phase: None, }, ]; - let initial_context = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - /tmp - zsh -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh personality".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; - - let refreshed = process_compacted_history(compacted_history, &initial_context); - let expected = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - /tmp - zsh -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh personality".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; + let (refreshed, mut expected) = + process_compacted_history_with_test_session(compacted_history).await; + expected.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + end_turn: None, + phase: None, + }); assert_eq!(refreshed, expected); } - #[test] - fn process_compacted_history_reinjects_full_initial_context() { + #[tokio::test] + async fn process_compacted_history_reinjects_full_initial_context() { let compacted_history = vec![ResponseItem::Message { id: None, role: "user".to_string(), @@ -830,124 +826,22 @@ do things end_turn: None, phase: None, }]; - let initial_context = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#"# AGENTS.md instructions for /repo - - -keep me updated -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - /repo - zsh -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - turn-1 - interrupted -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ]; - - let refreshed = process_compacted_history(compacted_history, &initial_context); - let expected = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#"# AGENTS.md instructions for /repo - - -keep me updated -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - /repo - zsh -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - turn-1 - interrupted -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; + let (refreshed, mut expected) = + process_compacted_history_with_test_session(compacted_history).await; + expected.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + end_turn: None, + phase: None, + }); assert_eq!(refreshed, expected); } - #[test] - fn process_compacted_history_drops_non_user_content_messages() { + #[tokio::test] + async fn process_compacted_history_drops_non_user_content_messages() { let compacted_history = vec![ ResponseItem::Message { id: None, @@ -1008,42 +902,22 @@ keep me updated phase: None, }, ]; - let initial_context = vec![ResponseItem::Message { + let (refreshed, mut expected) = + process_compacted_history_with_test_session(compacted_history).await; + expected.push(ResponseItem::Message { id: None, - role: "developer".to_string(), + role: "user".to_string(), content: vec![ContentItem::InputText { - text: "fresh developer instructions".to_string(), + text: "summary".to_string(), }], end_turn: None, phase: None, - }]; - - let refreshed = process_compacted_history(compacted_history, &initial_context); - let expected = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh developer instructions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; + }); assert_eq!(refreshed, expected); } - #[test] - fn process_compacted_history_inserts_context_before_last_real_user_message_only() { + #[tokio::test] + async fn process_compacted_history_inserts_context_before_last_real_user_message_only() { let compacted_history = vec![ ResponseItem::Message { id: None, @@ -1073,18 +947,10 @@ keep me updated phase: None, }, ]; - let initial_context = vec![ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }]; - let refreshed = process_compacted_history(compacted_history, &initial_context); - let expected = vec![ + let (refreshed, initial_context) = + process_compacted_history_with_test_session(compacted_history).await; + let mut expected = vec![ ResponseItem::Message { id: None, role: "user".to_string(), @@ -1103,6 +969,75 @@ keep me updated end_turn: None, phase: None, }, + ]; + expected.extend(initial_context); + expected.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "latest user".to_string(), + }], + end_turn: None, + phase: None, + }); + assert_eq!(refreshed, expected); + } + + #[test] + fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() { + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "latest user".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + end_turn: None, + phase: None, + }, + ]; + let initial_context = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }]; + + let refreshed = insert_initial_context_before_last_real_user_or_summary( + compacted_history, + initial_context, + ); + let expected = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + end_turn: None, + phase: None, + }, ResponseItem::Message { id: None, role: "developer".to_string(), @@ -1121,6 +1056,51 @@ keep me updated end_turn: None, phase: None, }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + end_turn: None, + phase: None, + }, + ]; + assert_eq!(refreshed, expected); + } + + #[test] + fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last() { + let compacted_history = vec![ResponseItem::Compaction { + encrypted_content: "encrypted".to_string(), + }]; + let initial_context = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }]; + + let refreshed = insert_initial_context_before_last_real_user_or_summary( + compacted_history, + initial_context, + ); + let expected = vec![ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Compaction { + encrypted_content: "encrypted".to_string(), + }, ]; assert_eq!(refreshed, expected); } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 3d181f8855..2a9106c722 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; +use crate::compact::InitialContextInjection; use crate::compact::extract_trailing_model_switch_update_for_compaction_request; +use crate::compact::insert_initial_context_before_last_real_user_or_summary; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::context_manager::estimate_response_item_model_visible_bytes; @@ -25,8 +27,9 @@ use tracing::info; pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, turn_context: Arc, + initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { - run_remote_compact_task_inner(&sess, &turn_context).await?; + run_remote_compact_task_inner(&sess, &turn_context, initial_context_injection).await?; Ok(()) } @@ -41,14 +44,17 @@ pub(crate) async fn run_remote_compact_task( }); sess.send_event(&turn_context, start_event).await; - run_remote_compact_task_inner(&sess, &turn_context).await + run_remote_compact_task_inner(&sess, &turn_context, InitialContextInjection::DoNotInject).await } async fn run_remote_compact_task_inner( sess: &Arc, turn_context: &Arc, + initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { - if let Err(err) = run_remote_compact_task_inner_impl(sess, turn_context).await { + if let Err(err) = + run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await + { let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), ); @@ -61,6 +67,7 @@ async fn run_remote_compact_task_inner( async fn run_remote_compact_task_inner_impl( sess: &Arc, turn_context: &Arc, + initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(turn_context, &compaction_item) @@ -122,9 +129,13 @@ async fn run_remote_compact_task_inner_impl( Err(err) }) .await?; - new_history = sess - .process_compacted_history(turn_context, new_history) - .await; + new_history = process_compacted_history( + sess.as_ref(), + turn_context.as_ref(), + new_history, + initial_context_injection, + ) + .await; // Reattach the stripped model-switch update only after successful compaction so the model // still sees the switch instructions on the next real sampling request. if let Some(model_switch_item) = stripped_model_switch_item { @@ -134,7 +145,12 @@ async fn run_remote_compact_task_inner_impl( if !ghost_snapshots.is_empty() { new_history.extend(ghost_snapshots); } - sess.replace_history(new_history.clone()).await; + let reference_context_item = match initial_context_injection { + InitialContextInjection::DoNotInject => None, + InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()), + }; + sess.replace_history(new_history.clone(), reference_context_item) + .await; sess.recompute_token_usage(turn_context).await; let compacted_item = CompactedItem { @@ -149,6 +165,65 @@ async fn run_remote_compact_task_inner_impl( Ok(()) } +pub(crate) async fn process_compacted_history( + sess: &Session, + turn_context: &TurnContext, + mut compacted_history: Vec, + initial_context_injection: InitialContextInjection, +) -> Vec { + // Mid-turn compaction is the only path that must inject initial context above the last user + // message in the replacement history. Pre-turn compaction instead injects context after the + // compaction item, but mid-turn compaction keeps the compaction item last for model training. + let initial_context = if matches!( + initial_context_injection, + InitialContextInjection::BeforeLastUserMessage + ) { + sess.build_initial_context(turn_context).await + } else { + Vec::new() + }; + + compacted_history.retain(should_keep_compacted_history_item); + insert_initial_context_before_last_real_user_or_summary(compacted_history, initial_context) +} + +/// Returns whether an item from remote compaction output should be preserved. +/// +/// Called while processing the model-provided compacted transcript, before we +/// append fresh canonical context from the current session. +/// +/// We drop: +/// - `developer` messages because remote output can include stale/duplicated +/// instruction content. +/// - non-user-content `user` messages (session prefix/instruction wrappers), +/// keeping only real user messages as parsed by `parse_turn_item`. +/// +/// This intentionally keeps: +/// - `assistant` messages (future remote compaction models may emit them) +/// - `user`-role warnings and compaction-generated summary messages because +/// they parse as `TurnItem::UserMessage`. +fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { + match item { + ResponseItem::Message { role, .. } if role == "developer" => false, + ResponseItem::Message { role, .. } if role == "user" => matches!( + crate::event_mapping::parse_turn_item(item), + Some(TurnItem::UserMessage(_)) + ), + ResponseItem::Message { role, .. } if role == "assistant" => true, + ResponseItem::Message { .. } => false, + ResponseItem::Compaction { .. } => true, + ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::GhostSnapshot { .. } + | ResponseItem::Other => false, + } +} + #[derive(Debug)] struct CompactRequestLogData { failing_compaction_request_model_visible_bytes: i64, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 0cd935523e..52df99fb02 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -27,12 +27,15 @@ pub(crate) struct ContextManager { /// The oldest items are at the beginning of the vector. items: Vec, token_info: Option, - /// Previous turn context snapshot used for diffing context and producing - /// model-visible settings update items. + /// Reference context snapshot used for diffing and producing model-visible + /// settings update items. + /// + /// This is the baseline for the next regular model turn, and may already + /// match the current turn after context updates are persisted. /// /// When this is `None`, settings diffing treats the next turn as having no /// baseline and emits a full reinjection of context state. - previous_context_item: Option, + reference_context_item: Option, } #[derive(Debug, Clone, Copy, Default)] @@ -48,7 +51,7 @@ impl ContextManager { Self { items: Vec::new(), token_info: TokenUsageInfo::new_or_append(&None, &None, None), - previous_context_item: None, + reference_context_item: None, } } @@ -60,12 +63,12 @@ impl ContextManager { self.token_info = info; } - pub(crate) fn set_previous_context_item(&mut self, item: Option) { - self.previous_context_item = item; + pub(crate) fn set_reference_context_item(&mut self, item: Option) { + self.reference_context_item = item; } - pub(crate) fn previous_context_item(&self) -> Option { - self.previous_context_item.clone() + pub(crate) fn reference_context_item(&self) -> Option { + self.reference_context_item.clone() } pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 217889a724..cdb53ff0c4 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -97,11 +97,10 @@ pub(crate) fn personality_message_for( } pub(crate) fn build_model_instructions_update_item( - previous: Option<&TurnContextItem>, - resumed_model: Option<&str>, + previous_user_turn_model: Option<&str>, next: &TurnContext, ) -> Option { - let previous_model = resumed_model.or_else(|| previous.map(|prev| prev.model.as_str()))?; + let previous_model = previous_user_turn_model?; if previous_model == next.model_info.slug { return None; } @@ -116,7 +115,7 @@ pub(crate) fn build_model_instructions_update_item( pub(crate) fn build_settings_update_items( previous: Option<&TurnContextItem>, - resumed_model: Option<&str>, + previous_user_turn_model: Option<&str>, next: &TurnContext, shell: &Shell, exec_policy: &Policy, @@ -124,6 +123,13 @@ pub(crate) fn build_settings_update_items( ) -> Vec { let mut update_items = Vec::new(); + // Keep model-switch instructions first so model-specific guidance is read before + // any other context diffs on this turn. + if let Some(model_instructions_item) = + build_model_instructions_update_item(previous_user_turn_model, next) + { + update_items.push(model_instructions_item); + } if let Some(env_item) = build_environment_update_item(previous, next, shell) { update_items.push(env_item); } @@ -133,11 +139,6 @@ pub(crate) fn build_settings_update_items( if let Some(collaboration_mode_item) = build_collaboration_mode_update_item(previous, next) { update_items.push(collaboration_mode_item); } - if let Some(model_instructions_item) = - build_model_instructions_update_item(previous, resumed_model, next) - { - update_items.push(model_instructions_item); - } if let Some(personality_item) = build_personality_update_item(previous, next, personality_feature_enabled) { diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 76c365ab5b..160225174f 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -21,12 +21,9 @@ pub(crate) struct SessionState { pub(crate) server_reasoning_included: bool, pub(crate) dependency_env: HashMap, pub(crate) mcp_dependency_prompted: HashSet, - /// Whether the session's initial context has been seeded into history. - /// - /// TODO(owen): This is a temporary solution to avoid updating a thread's updated_at - /// timestamp when resuming a session. Remove this once SQLite is in place. - pub(crate) initial_context_seeded: bool, - /// Previous model seen by the session, used for model-switch handling on task start. + /// Model used by the latest regular user turn, used for model-switch handling + /// on subsequent regular turns (including full-context reinjection after + /// resume or `/compact`). previous_model: Option, /// Startup regular task pre-created during session initialization. pub(crate) startup_regular_task: Option, @@ -45,7 +42,6 @@ impl SessionState { server_reasoning_included: false, dependency_env: HashMap::new(), mcp_dependency_prompted: HashSet::new(), - initial_context_seeded: false, previous_model: None, startup_regular_task: None, active_mcp_tool_selection: None, @@ -73,20 +69,26 @@ impl SessionState { self.history.clone() } - pub(crate) fn replace_history(&mut self, items: Vec) { + pub(crate) fn replace_history( + &mut self, + items: Vec, + reference_context_item: Option, + ) { self.history.replace(items); + self.history + .set_reference_context_item(reference_context_item); } pub(crate) fn set_token_info(&mut self, info: Option) { self.history.set_token_info(info); } - pub(crate) fn set_previous_context_item(&mut self, item: Option) { - self.history.set_previous_context_item(item); + pub(crate) fn set_reference_context_item(&mut self, item: Option) { + self.history.set_reference_context_item(item); } - pub(crate) fn previous_context_item(&self) -> Option { - self.history.previous_context_item() + pub(crate) fn reference_context_item(&self) -> Option { + self.history.reference_context_item() } // Token/rate limit helpers diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index b56f7b1df5..db2a494c3e 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -25,22 +25,21 @@ impl SessionTask for CompactTask { _cancellation_token: CancellationToken, ) -> Option { let session = session.clone_session(); - if crate::compact::should_use_remote_compact_task(&ctx.provider) { + let _ = if crate::compact::should_use_remote_compact_task(&ctx.provider) { let _ = session.services.otel_manager.counter( "codex.task.compact", 1, &[("type", "remote")], ); - let _ = crate::compact_remote::run_remote_compact_task(session, ctx).await; + crate::compact_remote::run_remote_compact_task(session.clone(), ctx).await } else { let _ = session.services.otel_manager.counter( "codex.task.compact", 1, &[("type", "local")], ); - let _ = crate::compact::run_compact_task(session, ctx, input).await; - } - + crate::compact::run_compact_task(session.clone(), ctx, input).await + }; None } } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index eb04619ee9..861b18a52c 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -121,8 +121,6 @@ impl Session { ) { self.abort_all_tasks(TurnAbortReason::Replaced).await; self.clear_connector_selection().await; - self.seed_initial_context_if_needed(turn_context.as_ref()) - .await; let task: Arc = Arc::new(task); let task_kind = task.kind(); @@ -140,7 +138,6 @@ impl Session { tokio::spawn( async move { let ctx_for_finish = Arc::clone(&ctx); - let model_slug = ctx_for_finish.model_info.slug.clone(); let last_agent_message = task_for_run .run( Arc::clone(&session_ctx), @@ -151,9 +148,6 @@ impl Session { .await; let sess = session_ctx.clone_session(); sess.flush_rollout().await; - // Update previous model before TurnComplete is emitted so - // immediately following turns observe the correct switch state. - sess.set_previous_model(Some(model_slug)).await; if !task_cancellation_token.is_cancelled() { // Emit completion uniformly from spawn site so all tasks share the same lifecycle. sess.on_task_finished(Arc::clone(&ctx_for_finish), last_agent_message) @@ -278,10 +272,6 @@ impl Session { task.handle.abort(); - // Set previous model even when interrupted so model-switch handling stays correct. - self.set_previous_model(Some(task.turn_context.model_info.slug.clone())) - .await; - let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); session_task .abort(session_ctx, Arc::clone(&task.turn_context)) diff --git a/codex-rs/core/src/tasks/undo.rs b/codex-rs/core/src/tasks/undo.rs index bb3b6d3152..b2fb4577a8 100644 --- a/codex-rs/core/src/tasks/undo.rs +++ b/codex-rs/core/src/tasks/undo.rs @@ -101,7 +101,8 @@ impl SessionTask for UndoTask { match restore_result { Ok(Ok(())) => { items.remove(idx); - sess.replace_history(items).await; + let reference_context_item = sess.reference_context_item().await; + sess.replace_history(items, reference_context_item).await; let short_id: String = commit_id.chars().take(7).collect(); info!(commit_id = commit_id, "Undo restored ghost snapshot"); completed.success = true; diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index d356193f58..3e378fcd7c 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -101,6 +101,10 @@ pub(crate) async fn execute_user_shell_command( // Auxiliary mode runs within an existing active turn. That turn already // emitted TurnStarted, so emitting another TurnStarted here would create // duplicate turn lifecycle events and confuse clients. + // TODO(ccunningham): After TurnStarted, emit model-visible turn context diffs for + // standalone lifecycle tasks (for example /shell, and review once it emits TurnStarted). + // `/compact` is an intentional exception because compaction requests should not include + // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), model_context_window: turn_context.model_context_window(), diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 702e13f81d..649d1cf0bc 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -867,7 +867,7 @@ pub async fn mount_compact_json_once(server: &MockServer, body: serde_json::Valu /// Mount a `/responses/compact` mock that mirrors the default remote compaction shape: /// keep user+developer messages from the request, drop assistant/tool artifacts, and append one -/// summary user message. +/// compaction item carrying the provided summary text. pub async fn mount_compact_user_history_with_summary_once( server: &MockServer, summary_text: &str, @@ -911,6 +911,9 @@ pub async fn mount_compact_user_history_with_summary_sequence( .cloned() .unwrap_or_default() .into_iter() + // TODO(ccunningham): Update this mock to match future compaction model behavior: + // return user/developer/assistant messages since the last compaction item, then + // append a single newest compaction item. // Match current remote compaction behavior: keep user/developer messages and // omit assistant/tool history entries. .filter(|item| { @@ -921,11 +924,10 @@ pub async fn mount_compact_user_history_with_summary_sequence( ) }) .collect::>(); - // Append the synthetic summary message as the newest user item. + // Append a synthetic compaction item as the newest item. output.push(serde_json::json!({ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": summary_text}], + "type": "compaction", + "encrypted_content": summary_text, })); ResponseTemplate::new(200) .insert_header("content-type", "application/json") diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 08850ec38f..c4745ab20e 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -363,7 +363,7 @@ async fn summarize_context_three_requests_and_instructions() { codex.submit(Op::Shutdown).await.unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; - // Verify rollout contains APITurn entries for each API call and a Compacted entry. + // Verify rollout contains regular sampling TurnContext entries and a Compacted entry. println!("rollout path: {}", rollout_path.display()); let text = std::fs::read_to_string(&rollout_path).unwrap_or_else(|e| { panic!( @@ -371,7 +371,7 @@ async fn summarize_context_three_requests_and_instructions() { rollout_path.display() ) }); - let mut api_turn_count = 0usize; + let mut regular_turn_context_count = 0usize; let mut saw_compacted_summary = false; for line in text.lines() { let trimmed = line.trim(); @@ -383,7 +383,7 @@ async fn summarize_context_three_requests_and_instructions() { }; match entry.item { RolloutItem::TurnContext(_) => { - api_turn_count += 1; + regular_turn_context_count += 1; } RolloutItem::Compacted(ci) => { if ci.message == expected_summary_message { @@ -395,8 +395,8 @@ async fn summarize_context_three_requests_and_instructions() { } assert!( - api_turn_count == 3, - "expected three APITurn entries in rollout" + regular_turn_context_count == 2, + "expected two regular sampling TurnContext entries in rollout" ); assert!( saw_compacted_summary, @@ -2414,26 +2414,36 @@ async fn manual_compact_twice_preserves_latest_user_messages() { first_request_user_texts[first_turn_user_index], first_user_message, "first turn request should end with the submitted user message" ); - let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; + let initial_seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; let final_request_user_texts = requests .last() .unwrap_or_else(|| panic!("final turn request missing for {final_user_message}")) .message_input_texts("user"); assert!( - final_request_user_texts - .as_slice() - .starts_with(seeded_user_prefix), - "final request should start with seeded user prefix from first request: {seeded_user_prefix:?}" + !initial_seeded_user_prefix.is_empty(), + "first turn should include seeded user prefix before the submitted user message" ); - let final_output = &final_request_user_texts[seeded_user_prefix.len()..]; - let expected = vec![ + let (final_request_last_user_text, final_request_before_last_user) = final_request_user_texts + .split_last() + .unwrap_or_else(|| panic!("final turn request missing user messages")); + assert_eq!( + final_request_last_user_text, final_user_message, + "final turn request should end with the submitted user message" + ); + let history_before_seeded_prefix = final_request_before_last_user + .strip_suffix(initial_seeded_user_prefix) + .unwrap_or_else(|| { + panic!( + "final request should end with the seeded user prefix from the first request: {initial_seeded_user_prefix:?}" + ) + }); + let expected_history = vec![ first_user_message.to_string(), second_user_message.to_string(), expected_second_summary, - final_user_message.to_string(), ]; - assert_eq!(final_output, expected.as_slice()); + assert_eq!(history_before_seeded_prefix, expected_history.as_slice()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index c9acde41ab..6baccc16f4 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -93,12 +93,9 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { ) .await; - let compacted_history = vec![ - responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), - ResponseItem::Compaction { - encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - }, - ]; + let compacted_history = vec![ResponseItem::Compaction { + encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), + }]; let compact_mock = responses::mount_compact_json_once( harness.server(), serde_json::json!({ "output": compacted_history.clone() }), @@ -159,7 +156,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { let follow_up_request = response_requests.last().expect("follow-up request missing"); let follow_up_body = follow_up_request.body_json().to_string(); assert!( - follow_up_body.contains("REMOTE_COMPACTED_SUMMARY"), + follow_up_body.contains("\"type\":\"compaction\""), "expected follow-up request to use compacted history" ); assert!( @@ -178,7 +175,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { insta::assert_snapshot!( "remote_manual_compact_with_history_shapes", format_labeled_requests_snapshot( - "Remote manual /compact where remote compact output is summary-only: follow-up layout uses returned summary plus new user message.", + "Remote manual /compact where remote compact output is compaction-only: follow-up layout uses the returned compaction item plus new user message.", &[ ("Remote Compaction Request", &compact_request), ("Remote Post-Compaction History Layout", follow_up_request), @@ -958,7 +955,6 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> .await; let compacted_history = vec![ - responses::user_message_item("COMPACTED_USER_SUMMARY"), ResponseItem::Compaction { encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), }, @@ -1012,17 +1008,6 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> && compacted.message.is_empty() && let Some(replacement_history) = compacted.replacement_history.as_ref() { - let has_compacted_user_summary = replacement_history.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "user" - && content.iter().any(|part| matches!( - part, - ContentItem::InputText { text } if text == "COMPACTED_USER_SUMMARY" - )) - ) - }); let has_compaction_item = replacement_history.iter().any(|item| { matches!( item, @@ -1054,7 +1039,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> ) }); - if has_compacted_user_summary && has_compaction_item && has_compacted_assistant_note { + if has_compaction_item && has_compacted_assistant_note { assert!( !has_permissions_developer_message, "manual remote compact rollout replacement history should not inject permissions context" @@ -1110,7 +1095,6 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res .await; let compacted_history = vec![ - responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Message { id: None, role: "developer".to_string(), @@ -1196,8 +1180,8 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res "fresh developer instructions should be present after compaction" ); assert!( - after_compact_body.contains("REMOTE_COMPACTED_SUMMARY"), - "compacted summary should be present after compaction" + after_compact_body.contains("ENCRYPTED_COMPACTION_SUMMARY"), + "compaction item should be present after compaction" ); let after_resume_body = after_resume_request.body_json().to_string(); @@ -1210,8 +1194,8 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res "fresh developer instructions should be present after resume" ); assert!( - after_resume_body.contains("REMOTE_COMPACTED_SUMMARY"), - "compacted summary should persist after resume" + after_resume_body.contains("ENCRYPTED_COMPACTION_SUMMARY"), + "compaction item should persist after resume" ); Ok(()) @@ -1243,7 +1227,6 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() .await; let compacted_history = vec![ - responses::user_message_item("REMOTE_COMPACTED_SUMMARY"), ResponseItem::Message { id: None, role: "developer".to_string(), @@ -1302,8 +1285,8 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() "fresh developer instructions should be present after compaction" ); assert!( - after_compact_body.contains("REMOTE_COMPACTED_SUMMARY"), - "compacted summary should be present after compaction" + after_compact_body.contains("ENCRYPTED_COMPACTION_SUMMARY"), + "compaction item should be present after compaction" ); Ok(()) @@ -1706,7 +1689,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res insta::assert_snapshot!( "remote_mid_turn_compaction_shapes", format_labeled_requests_snapshot( - "Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.", + "Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and the follow-up request includes the returned compaction item.", &[ ("Remote Compaction Request", &compact_request), ("Remote Post-Compaction History Layout", &requests[1]), @@ -1749,9 +1732,9 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject ) .await; - let compacted_history = vec![responses::user_message_item(&summary_with_prefix( - "REMOTE_SUMMARY_ONLY", - ))]; + let compacted_history = vec![ResponseItem::Compaction { + encrypted_content: summary_with_prefix("REMOTE_SUMMARY_ONLY"), + }]; let compact_mock = responses::mount_compact_json_once( harness.server(), serde_json::json!({ "output": compacted_history }), @@ -1786,7 +1769,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject insta::assert_snapshot!( "remote_mid_turn_compaction_summary_only_reinjects_context_shapes", format_labeled_requests_snapshot( - "Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary.", + "Remote mid-turn compaction where compact output has only a compaction item: continuation layout reinjects context before that compaction item.", &[ ("Remote Compaction Request", &compact_request), ( @@ -1893,13 +1876,13 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec insta::assert_snapshot!( "remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes", format_labeled_requests_snapshot( - "Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request.", + "After a prior manual /compact produced an older remote compaction item, the next turn hits remote auto-compaction before the next sampling request. The compact request carries forward that earlier compaction item, and the next sampling request shows the latest compaction item with context reinjected before USER_TWO.", &[ + ("Remote Compaction Request", &compact_request), ( - "Second Turn Request (Before Mid-Turn Compaction)", + "Second Turn Request (After Compaction)", &second_turn_request ), - ("Remote Compaction Request", &compact_request), ] ) ); diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index fc77a2621f..b66edb84aa 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -81,25 +81,32 @@ fn normalize_line_endings_str(text: &str) -> String { } } -fn extract_summary_message(request: &Value, summary_text: &str) -> Value { +fn extract_summary_user_text(request: &Value, summary_text: &str) -> String { + json_message_input_texts(request, "user") + .into_iter() + .find(|text| text.contains(summary_text)) + .unwrap_or_else(|| panic!("expected summary message {summary_text}")) +} + +fn json_message_input_texts(request: &Value, role: &str) -> Vec { request .get("input") .and_then(Value::as_array) - .and_then(|items| { - items.iter().find(|item| { - item.get("type").and_then(Value::as_str) == Some("message") - && item.get("role").and_then(Value::as_str) == Some("user") - && item - .get("content") - .and_then(Value::as_array) - .and_then(|arr| arr.first()) - .and_then(|entry| entry.get("text")) - .and_then(Value::as_str) - .is_some_and(|text| text.contains(summary_text)) - }) + .into_iter() + .flatten() + .filter(|item| { + item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some(role) }) - .cloned() - .unwrap_or_else(|| panic!("expected summary message {summary_text}")) + .filter_map(|item| { + item.get("content") + .and_then(Value::as_array) + .and_then(|content| content.first()) + .and_then(|entry| entry.get("text")) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect() } fn normalize_compact_prompts(requests: &mut [Value]) { @@ -200,459 +207,88 @@ async fn compact_resume_and_fork_preserve_model_history_view() { &fork_arr[..compact_arr.len()] ); - let expected_model = requests[0]["model"] - .as_str() - .unwrap_or_default() - .to_string(); - let prompt = requests[0]["instructions"] - .as_str() - .unwrap_or_default() - .to_string(); - let permissions_message = requests[0]["input"][0].clone(); - let user_instructions = requests[0]["input"][1]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - let environment_context = requests[0]["input"][2]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - let tool_calls = json!(requests[0]["tools"].as_array()); - let prompt_cache_key = requests[0]["prompt_cache_key"] - .as_str() - .unwrap_or_default() - .to_string(); - let fork_prompt_cache_key = requests[requests.len() - 1]["prompt_cache_key"] - .as_str() - .unwrap_or_default() - .to_string(); - let summary_after_compact = extract_summary_message(&requests[2], SUMMARY_TEXT); - let summary_after_resume = extract_summary_message(&requests[3], SUMMARY_TEXT); - let summary_after_fork = extract_summary_message(&requests[4], SUMMARY_TEXT); - let user_turn_1 = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] + let first_request_user_texts = json_message_input_texts(&requests[0], "user"); + let first_turn_user_index = first_request_user_texts + .len() + .checked_sub(1) + .unwrap_or_else(|| panic!("first turn request missing user messages")); + assert_eq!( + first_request_user_texts[first_turn_user_index], + "hello world" + ); + let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; + let summary_after_compact = extract_summary_user_text(&requests[2], SUMMARY_TEXT); + let summary_after_resume = extract_summary_user_text(&requests[3], SUMMARY_TEXT); + let summary_after_fork = extract_summary_user_text(&requests[4], SUMMARY_TEXT); + let mut expected_after_compact_user_texts = + vec!["hello world".to_string(), summary_after_compact]; + expected_after_compact_user_texts.extend_from_slice(seeded_user_prefix); + expected_after_compact_user_texts.push("AFTER_COMPACT".to_string()); + assert_eq!( + json_message_input_texts(&requests[2], "user"), + expected_after_compact_user_texts + ); + + let mut expected_after_resume_user_texts = + vec!["hello world".to_string(), summary_after_resume]; + expected_after_resume_user_texts.extend_from_slice(seeded_user_prefix); + expected_after_resume_user_texts.push("AFTER_COMPACT".to_string()); + let after_resume_user_texts = json_message_input_texts(&requests[3], "user"); + let (after_resume_last, after_resume_prefix) = after_resume_user_texts + .split_last() + .unwrap_or_else(|| panic!("after-resume request missing user messages")); + assert_eq!(after_resume_last, "AFTER_RESUME"); + assert!( + after_resume_prefix.starts_with(&expected_after_resume_user_texts), + "after-resume user texts should preserve compacted history prefix" + ); + let after_resume_seeded_suffix = &after_resume_prefix[expected_after_resume_user_texts.len()..]; + if seeded_user_prefix.is_empty() { + assert!( + after_resume_seeded_suffix.is_empty(), + "after-resume request should not append unexpected user prefix items" + ); + } else { + let mut chunks = after_resume_seeded_suffix.chunks_exact(seeded_user_prefix.len()); + assert!( + chunks.remainder().is_empty(), + "after-resume suffix should be whole seeded-prefix repeats" + ); + for chunk in &mut chunks { + assert_eq!(chunk, seeded_user_prefix); } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let compact_1 = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "FIRST_REPLY" - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": SUMMARIZATION_PROMPT - } - ] + } + + let after_fork_user_texts = json_message_input_texts(&requests[4], "user"); + let mut expected_after_fork_history_prefix = + vec!["hello world".to_string(), summary_after_fork]; + expected_after_fork_history_prefix.extend_from_slice(seeded_user_prefix); + expected_after_fork_history_prefix.push("AFTER_COMPACT".to_string()); + let (after_fork_last, after_fork_prefix) = after_fork_user_texts + .split_last() + .unwrap_or_else(|| panic!("after-fork request missing user messages")); + assert_eq!(after_fork_last, "AFTER_FORK"); + assert!( + after_fork_prefix.starts_with(&expected_after_fork_history_prefix), + "after-fork user texts should preserve compacted user history prefix" + ); + let after_fork_seeded_suffix = &after_fork_prefix[expected_after_fork_history_prefix.len()..]; + if seeded_user_prefix.is_empty() { + assert!( + after_fork_seeded_suffix.is_empty(), + "after-fork request should not append unexpected user prefix items" + ); + } else { + let mut chunks = after_fork_seeded_suffix.chunks_exact(seeded_user_prefix.len()); + assert!( + chunks.remainder().is_empty(), + "after-fork suffix should be whole seeded-prefix repeats" + ); + for chunk in &mut chunks { + assert_eq!(chunk, seeded_user_prefix); } - ], - "tools": [], - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let user_turn_2_after_compact = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - summary_after_compact, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let usert_turn_3_after_resume = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - summary_after_resume, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "AFTER_COMPACT_REPLY" - } - ] - }, - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_RESUME" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let user_turn_3_after_fork = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - summary_after_fork, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "AFTER_COMPACT_REPLY" - } - ] - }, - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_FORK" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": fork_prompt_cache_key - }); - let mut expected = json!([ - user_turn_1, - compact_1, - user_turn_2_after_compact, - usert_turn_3_after_resume, - user_turn_3_after_fork - ]); - normalize_line_endings(&mut expected); - if let Some(arr) = expected.as_array_mut() { - normalize_compact_prompts(arr); } assert_eq!(requests.len(), 5); - assert_eq!(json!(requests), expected); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -725,118 +361,47 @@ async fn compact_resume_after_second_compaction_preserves_history() { compact_filtered.as_slice(), &resume_filtered[..compact_filtered.len()] ); - // hard coded test - let prompt = requests[0]["instructions"] - .as_str() - .unwrap_or_default() - .to_string(); - let permissions_message = requests[0]["input"][0].clone(); - let user_instructions = requests[0]["input"][1]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - let environment_instructions = requests[0]["input"][2]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - - // Build expected final request input: initial context + forked user message + - // compacted summary + post-compact user message + resumed user message. + let first_request_user_texts = json_message_input_texts(&requests[0], "user"); + let first_turn_user_index = first_request_user_texts + .len() + .checked_sub(1) + .unwrap_or_else(|| panic!("first turn request missing user messages")); + assert_eq!( + first_request_user_texts[first_turn_user_index], + "hello world" + ); + let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; let summary_after_second_compact = - extract_summary_message(&requests[requests.len() - 3], SUMMARY_TEXT); - - let mut expected = json!([ - { - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_FORK" - } - ] - }, - summary_after_second_compact, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT_2" - } - ] - }, - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_SECOND_RESUME" - } - ] - } - ], - } - ]); - normalize_line_endings(&mut expected); - let mut last_request_after_2_compacts = json!([{ - "instructions": requests[requests.len() -1]["instructions"], - "input": requests[requests.len() -1]["input"], - }]); - if let Some(arr) = expected.as_array_mut() { - normalize_compact_prompts(arr); + extract_summary_user_text(&requests[requests.len() - 3], SUMMARY_TEXT); + let mut expected_after_second_compact_user_texts = + vec!["AFTER_FORK".to_string(), summary_after_second_compact]; + expected_after_second_compact_user_texts.extend_from_slice(seeded_user_prefix); + expected_after_second_compact_user_texts.push("AFTER_COMPACT_2".to_string()); + let final_user_texts = json_message_input_texts(&requests[requests.len() - 1], "user"); + let (final_last, final_prefix) = final_user_texts + .split_last() + .unwrap_or_else(|| panic!("after-second-resume request missing user messages")); + assert_eq!(final_last, AFTER_SECOND_RESUME); + assert!( + final_prefix.starts_with(&expected_after_second_compact_user_texts), + "after-second-resume user texts should preserve post-compact user history prefix" + ); + let final_seeded_suffix = &final_prefix[expected_after_second_compact_user_texts.len()..]; + if seeded_user_prefix.is_empty() { + assert!( + final_seeded_suffix.is_empty(), + "after-second-resume request should not append unexpected user prefix items" + ); + } else { + let mut chunks = final_seeded_suffix.chunks_exact(seeded_user_prefix.len()); + assert!( + chunks.remainder().is_empty(), + "after-second-resume suffix should be whole seeded-prefix repeats" + ); + for chunk in &mut chunks { + assert_eq!(chunk, seeded_user_prefix); + } } - if let Some(arr) = last_request_after_2_compacts.as_array_mut() { - normalize_compact_prompts(arr); - } - assert_eq!(expected, last_request_after_2_compacts); } fn normalize_line_endings(value: &mut Value) { diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index eda1b89151..f3daf06c8b 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -308,6 +308,8 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul config.personality = Some(Personality::Pragmatic); }); let resumed = resume_builder.resume(&server, home, rollout_path).await?; + let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); + fs::create_dir_all(&resume_override_cwd)?; resumed .codex .submit(Op::UserTurn { @@ -316,7 +318,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: resumed.cwd_path().to_path_buf(), + cwd: resume_override_cwd, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: resumed.session_configured.model.clone(), @@ -398,10 +400,12 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - config.model = Some("gpt-5.2-codex".to_string()); }); let resumed = resume_builder.resume(&server, home, rollout_path).await?; + let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); + fs::create_dir_all(&resume_override_cwd)?; resumed .codex .submit(Op::OverrideTurnContext { - cwd: None, + cwd: Some(resume_override_cwd), approval_policy: None, sandbox_policy: None, windows_sandbox_level: None, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index cd3b4272a0..351d1d5752 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -672,12 +672,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }); let expected_permissions_msg = body1["input"][0].clone(); let body1_input = body1["input"].as_array().expect("input array"); - let expected_permissions_msg_2 = body2["input"][body1_input.len() + 1].clone(); - assert_ne!( - expected_permissions_msg_2, expected_permissions_msg, - "expected updated permissions message after per-turn override" - ); - let expected_model_switch_msg = body2["input"][body1_input.len() + 2].clone(); + let expected_model_switch_msg = body2["input"][body1_input.len()].clone(); assert_eq!( expected_model_switch_msg["role"].as_str(), Some("developer") @@ -688,10 +683,15 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res .is_some_and(|text| text.contains("")), "expected model switch message after model override: {expected_model_switch_msg:?}" ); + let expected_permissions_msg_2 = body2["input"][body1_input.len() + 2].clone(); + assert_ne!( + expected_permissions_msg_2, expected_permissions_msg, + "expected updated permissions message after per-turn override" + ); let mut expected_body2 = body1_input.to_vec(); + expected_body2.push(expected_model_switch_msg); expected_body2.push(expected_env_msg_2); expected_body2.push(expected_permissions_msg_2); - expected_body2.push(expected_model_switch_msg); expected_body2.push(expected_user_message_2); assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); @@ -900,12 +900,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu assert_eq!(body1["input"], expected_input_1); let body1_input = body1["input"].as_array().expect("input array"); - let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone(); - assert_ne!( - expected_permissions_msg_2, expected_permissions_msg, - "expected updated permissions message after policy change" - ); - let expected_model_switch_msg = body2["input"][body1_input.len() + 1].clone(); + let expected_model_switch_msg = body2["input"][body1_input.len()].clone(); assert_eq!( expected_model_switch_msg["role"].as_str(), Some("developer") @@ -916,14 +911,19 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu .is_some_and(|text| text.contains("")), "expected model switch message after model override: {expected_model_switch_msg:?}" ); + let expected_permissions_msg_2 = body2["input"][body1_input.len() + 1].clone(); + assert_ne!( + expected_permissions_msg_2, expected_permissions_msg, + "expected updated permissions message after policy change" + ); let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ expected_permissions_msg, expected_ui_msg, expected_env_msg_1, expected_user_message_1, - expected_permissions_msg_2, expected_model_switch_msg, + expected_permissions_msg_2, expected_user_message_2, ]); assert_eq!(body2["input"], expected_input_2); diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 61726f913c..89a08b9e55 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -6,9 +6,13 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InitialHistory; use codex_core::protocol::ResumedHistory; use codex_core::protocol::RolloutItem; +use codex_core::protocol::TurnCompleteEvent; use codex_core::protocol::TurnContextItem; +use codex_core::protocol::TurnStartedEvent; +use codex_core::protocol::UserMessageEvent; use codex_core::protocol::WarningEvent; use codex_protocol::ThreadId; +use codex_protocol::config_types::ModeKind; use core::time::Duration; use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; @@ -19,8 +23,9 @@ fn resume_history( previous_model: &str, rollout_path: &std::path::Path, ) -> InitialHistory { + let turn_id = "resume-warning-seed-turn".to_string(); let turn_ctx = TurnContextItem { - turn_id: None, + turn_id: Some(turn_id.clone()), cwd: config.cwd.clone(), approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), @@ -38,7 +43,24 @@ fn resume_history( InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: vec![RolloutItem::TurnContext(turn_ctx)], + history: vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.clone(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "seed".to_string(), + images: None, + local_images: vec![], + text_elements: vec![], + })), + RolloutItem::TurnContext(turn_ctx), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id, + last_agent_message: None, + })), + ], rollout_path: rollout_path.to_path_buf(), }) } diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap index bcca5d3575..c88ff33122 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap @@ -13,9 +13,9 @@ Scenario: Manual /compact with prior user history compacts existing history and 05:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:first manual turn -04:message/user:\nFIRST_MANUAL_SUMMARY +00:message/user:first manual turn +01:message/user:\nFIRST_MANUAL_SUMMARY +02:message/developer: +03:message/user: +04:message/user:> 05:message/user:second manual turn diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap index bdb4fe9bae..0c8f87d04c 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap @@ -11,8 +11,8 @@ Scenario: Manual /compact with no prior user turn currently still issues a compa 03:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:\nMANUAL_EMPTY_SUMMARY +00:message/user:\nMANUAL_EMPTY_SUMMARY +01:message/developer: +02:message/user: +03:message/user:> 04:message/user:AFTER_MANUAL_EMPTY_COMPACT diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap index 8d6c4d9b1a..552c117c98 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap @@ -1,6 +1,5 @@ --- source: core/tests/suite/compact.rs -assertion_line: 1773 expression: "format_labeled_requests_snapshot(\"Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Pre-sampling Compaction Request\", &requests[1]),\n(\"Post-Compaction Follow-up Request (Next Model)\", &requests[2]),])" --- Scenario: Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message. @@ -22,10 +21,10 @@ Scenario: Pre-sampling compaction on model switch to a smaller context window: c 06:message/user: ## Post-Compaction Follow-up Request (Next Model) -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:before switch -04:message/user:\nPRE_SAMPLING_SUMMARY -05:message/developer:\nThe user was previously using a different model.... +00:message/user:before switch +01:message/user:\nPRE_SAMPLING_SUMMARY +02:message/developer:\nThe user was previously using a different model.... +03:message/developer: +04:message/user: +05:message/user:> 06:message/user:after switch diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap index e586c8521a..ea4fea2f21 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -12,14 +12,13 @@ Scenario: Pre-turn auto-compaction with a context override emits the context dif 04:message/assistant:FIRST_REPLY 05:message/user:USER_TWO 06:message/assistant:SECOND_REPLY -07:message/user: -08:message/user: +07:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user: -03:message/user:USER_ONE -04:message/user:USER_TWO -05:message/user:\nPRE_TURN_SUMMARY +00:message/user:USER_ONE +01:message/user:USER_TWO +02:message/user:\nPRE_TURN_SUMMARY +03:message/developer: +04:message/user: +05:message/user: 06:message/user: | | | USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap index 4d67af911d..59d3785a43 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,6 +1,5 @@ --- source: core/tests/suite/compact.rs -assertion_line: 3152 expression: "format_labeled_requests_snapshot(\"Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Local Compaction Request\", &requests[1]),\n(\"Local Post-Compaction History Layout\", &requests[2]),])" --- Scenario: Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request. @@ -22,11 +21,11 @@ Scenario: Pre-turn compaction during model switch (without pre-sampling model-sw 06:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/developer: The user has requested a new communication st... -02:message/user: -03:message/user:> -04:message/user:BEFORE_SWITCH_USER -05:message/user:\nPRETURN_SWITCH_SUMMARY -06:message/developer:\nThe user was previously using a different model.... +00:message/user:BEFORE_SWITCH_USER +01:message/user:\nPRETURN_SWITCH_SUMMARY +02:message/developer:\nThe user was previously using a different model.... +03:message/developer: +04:message/developer: The user has requested a new communication st... +05:message/user: +06:message/user:> 07:message/user:AFTER_SWITCH_USER diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap index bc795a19f4..701e676761 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap @@ -1,9 +1,8 @@ --- source: core/tests/suite/compact_remote.rs -assertion_line: 178 -expression: "format_labeled_requests_snapshot(\"Remote manual /compact where remote compact output is summary-only: follow-up layout uses returned summary plus new user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", follow_up_request),])" +expression: "format_labeled_requests_snapshot(\"Remote manual /compact where remote compact output is compaction-only: follow-up layout uses the returned compaction item plus new user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", follow_up_request),])" --- -Scenario: Remote manual /compact where remote compact output is summary-only: follow-up layout uses returned summary plus new user message. +Scenario: Remote manual /compact where remote compact output is compaction-only: follow-up layout uses the returned compaction item plus new user message. ## Remote Compaction Request 00:message/developer: @@ -13,9 +12,8 @@ Scenario: Remote manual /compact where remote compact output is summary-only: fo 04:message/assistant:FIRST_REMOTE_REPLY ## Remote Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:REMOTE_COMPACTED_SUMMARY -04:compaction:encrypted=true -05:message/user:after compact +00:compaction:encrypted=true +01:message/developer: +02:message/user: +03:message/user:> +04:message/user:after compact diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap index 1f51f96544..7d5bcde902 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap @@ -1,21 +1,18 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request.\",\n&[(\"Second Turn Request (Before Mid-Turn Compaction)\", &requests[1]),\n(\"Remote Compaction Request\", &compact_request),])" +assertion_line: 1876 +expression: "format_labeled_requests_snapshot(\"After a prior manual /compact produced an older remote compaction item, the next turn hits remote auto-compaction before the next sampling request. The compact request carries forward that earlier compaction item, and the next sampling request shows the latest compaction item with context reinjected before USER_TWO.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Second Turn Request (After Compaction)\", &second_turn_request),])" --- -Scenario: Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request. - -## Second Turn Request (Before Mid-Turn Compaction) -00:message/user:USER_ONE -01:message/user:\nREMOTE_OLDER_SUMMARY -02:message/developer: -03:message/user: -04:message/user:> -05:message/user:\nREMOTE_LATEST_SUMMARY -06:message/user:USER_TWO +Scenario: After a prior manual /compact produced an older remote compaction item, the next turn hits remote auto-compaction before the next sampling request. The compact request carries forward that earlier compaction item, and the next sampling request shows the latest compaction item with context reinjected before USER_TWO. ## Remote Compaction Request 00:message/user:USER_ONE -01:message/developer: -02:message/user: -03:message/user:> -04:message/user:\nREMOTE_OLDER_SUMMARY +01:compaction:encrypted=true + +## Second Turn Request (After Compaction) +00:message/user:USER_ONE +01:compaction:encrypted=true +02:message/developer: +03:message/user: +04:message/user:> +05:message/user:USER_TWO diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap index 8ef8701673..f5533b94a9 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" +expression: "format_labeled_requests_snapshot(\"Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and the follow-up request includes the returned compaction item.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" --- -Scenario: Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary. +Scenario: Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and the follow-up request includes the returned compaction item. ## Remote Compaction Request 00:message/developer: @@ -13,8 +13,8 @@ Scenario: Remote mid-turn continuation compaction after tool output: compact req 05:function_call_output:unsupported call: test_tool ## Remote Post-Compaction History Layout -00:message/user:USER_ONE -01:message/developer: -02:message/user: -03:message/user:> -04:message/user:\nREMOTE_MID_TURN_SUMMARY +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:compaction:encrypted=true diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap index 4ede5c3cad..3f8c77e975 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" +expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only a compaction item: continuation layout reinjects context before that compaction item.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" --- -Scenario: Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary. +Scenario: Remote mid-turn compaction where compact output has only a compaction item: continuation layout reinjects context before that compaction item. ## Remote Compaction Request 00:message/developer: @@ -16,4 +16,4 @@ Scenario: Remote mid-turn compaction where compact output has only summary user 00:message/developer: 01:message/user: 02:message/user:> -03:message/user:\nREMOTE_SUMMARY_ONLY +03:compaction:encrypted=true diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap index 00aaaeaa92..2236984a67 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap @@ -12,13 +12,12 @@ Scenario: Remote pre-turn auto-compaction with a context override emits the cont 04:message/assistant:REMOTE_FIRST_REPLY 05:message/user:USER_TWO 06:message/assistant:REMOTE_SECOND_REPLY -07:message/user: ## Remote Post-Compaction History Layout 00:message/user:USER_ONE 01:message/user:USER_TWO -02:message/developer: -03:message/user: -04:message/user: -05:message/user:\nREMOTE_PRE_TURN_SUMMARY +02:compaction:encrypted=true +03:message/developer: +04:message/user: +05:message/user: 06:message/user:USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap index fba17d719e..15beec5f27 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,6 +1,6 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming from the compact request payload, and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" +expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming from the compact request payload, and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &initial_turn_request),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" --- Scenario: Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming from the compact request payload, and restores it in the post-compaction follow-up request. @@ -19,10 +19,10 @@ Scenario: Remote pre-turn compaction during model switch currently excludes inco ## Remote Post-Compaction History Layout 00:message/user:BEFORE_SWITCH_USER -01:message/developer: -02:message/developer: The user has requested a new communication st... -03:message/user: -04:message/user:> -05:message/user:\nREMOTE_SWITCH_SUMMARY -06:message/developer:\nThe user was previously using a different model.... +01:compaction:encrypted=true +02:message/developer:\nThe user was previously using a different model.... +03:message/developer: +04:message/developer: The user has requested a new communication st... +05:message/user: +06:message/user:> 07:message/user:AFTER_SWITCH_USER diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_override_matches_rollout_model.snap b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_override_matches_rollout_model.snap index 850b6be0a2..06269eff76 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_override_matches_rollout_model.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_override_matches_rollout_model.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/model_visible_layout.rs +assertion_line: 435 expression: "format_labeled_requests_snapshot(\"First post-resume turn where pre-turn override sets model to rollout model; no model-switch update should appear.\",\n&[(\"Last Request Before Resume\", &initial_request),\n(\"First Request After Resume + Override\", &resumed_request),])" --- Scenario: First post-resume turn where pre-turn override sets model to rollout model; no model-switch update should appear. @@ -16,7 +17,5 @@ Scenario: First post-resume turn where pre-turn override sets model to rollout m 02:message/user:> 03:message/user:seed resume history 04:message/assistant:recorded before resume -05:message/developer: -06:message/user: -07:message/user:> -08:message/user:first resumed turn after model override +05:message/user: +06:message/user:first resumed turn after model override diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_with_personality_change.snap b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_with_personality_change.snap index a37cd885ec..54417c6de2 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_with_personality_change.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_resume_with_personality_change.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/model_visible_layout.rs +assertion_line: 337 expression: "format_labeled_requests_snapshot(\"First post-resume turn where resumed config model differs from rollout and personality changes.\",\n&[(\"Last Request Before Resume\", &initial_request),\n(\"First Request After Resume\", &resumed_request),])" --- Scenario: First post-resume turn where resumed config model differs from rollout and personality changes. @@ -16,11 +17,7 @@ Scenario: First post-resume turn where resumed config model differs from rollout 02:message/user:> 03:message/user:seed resume history 04:message/assistant:recorded before resume -05:message/developer: -06:message/developer: The user has requested a new communication style. Future messages should adhe... -07:message/user: -08:message/user:> -09:message/developer: -10:message/developer:\nThe user was previously using a different model. Please continue the conversatio... -11:message/developer: The user has requested a new communication style. Future messages should adhe... -12:message/user:resume and change personality +05:message/developer:\nThe user was previously using a different model. Please continue the conversatio... +06:message/user: +07:message/developer: +08:message/user:resume and change personality diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 86a1248657..a8b7ade166 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2088,6 +2088,10 @@ pub struct TurnContextNetworkItem { pub denied_domains: Vec, } +/// Persist only when the same turn also persists the corresponding +/// model-visible context updates (diffs or full reinjection), so +/// resume/fork does not use a `reference_context_item` whose context +/// was never actually visible to the model. #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] From 1a220ad77dc436082de5efc4e1f7fc30d711ab87 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 20 Feb 2026 23:19:29 -0800 Subject: [PATCH 030/282] chore: move config diagnostics out of codex-core (#12427) ## Why Compiling `codex-rs/core` is a bottleneck for local iteration, so this change continues the ongoing extraction of config-related functionality out of `codex-core` and into `codex-config`. The goal is not just to move code, but to reduce `codex-core` ownership and indirection so more code depends on `codex-config` directly. ## What Changed - Moved config diagnostics logic from `core/src/config_loader/diagnostics.rs` into `config/src/diagnostics.rs`. - Updated `codex-core` to use `codex-config` diagnostics types/functions directly where possible. - Removed the `core/src/config_loader/diagnostics.rs` shim module entirely; the remaining `ConfigToml`-specific calls are in `core/src/config_loader/mod.rs`. - Moved `CONFIG_TOML_FILE` into `codex-config` and updated existing references to use `codex_config::CONFIG_TOML_FILE` directly. - Added a direct `codex-config` dependency to `codex-cli` for its `CONFIG_TOML_FILE` use. --- codex-rs/Cargo.lock | 5 ++- codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 2 +- codex-rs/config/Cargo.toml | 4 ++ .../src}/diagnostics.rs | 44 +++++++++++-------- codex-rs/config/src/lib.rs | 14 ++++++ codex-rs/core/Cargo.toml | 1 - codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/config/edit.rs | 2 +- codex-rs/core/src/config/mod.rs | 3 +- codex-rs/core/src/config/service.rs | 2 +- codex-rs/core/src/config_loader/layer_io.rs | 4 +- codex-rs/core/src/config_loader/mod.rs | 32 +++++++++----- codex-rs/core/src/config_loader/tests.rs | 6 +-- codex-rs/core/src/features.rs | 2 +- codex-rs/core/src/network_proxy_loader.rs | 2 +- codex-rs/core/src/skills/loader.rs | 2 +- .../tests/suite/unstable_features_warning.rs | 2 +- 18 files changed, 82 insertions(+), 48 deletions(-) rename codex-rs/{core/src/config_loader => config/src}/diagnostics.rs (90%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f05879a243..4ef13ad845 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1470,6 +1470,7 @@ dependencies = [ "codex-arg0", "codex-chatgpt", "codex-cloud-tasks", + "codex-config", "codex-core", "codex-exec", "codex-execpolicy", @@ -1600,10 +1601,13 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", + "serde_path_to_error", "sha2", "thiserror 2.0.18", "tokio", "toml 0.9.11+spec-1.1.0", + "toml_edit 0.24.0+spec-1.1.0", + "tracing", ] [[package]] @@ -1681,7 +1685,6 @@ dependencies = [ "seccompiler", "serde", "serde_json", - "serde_path_to_error", "serde_yaml", "serial_test", "sha1", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index fd15f87916..1d955bbaaf 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -26,6 +26,7 @@ codex-arg0 = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } codex-utils-cli = { workspace = true } +codex-config = { workspace = true } codex-core = { workspace = true } codex-exec = { workspace = true } codex-execpolicy = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 33c9f161bb..98a5ab0779 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -870,7 +870,7 @@ fn maybe_print_under_development_feature_warning( return; } - let config_path = codex_home.join(codex_core::config::CONFIG_TOML_FILE); + let config_path = codex_home.join(codex_config::CONFIG_TOML_FILE); eprintln!( "Under-development features enabled: {feature}. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in {}.", config_path.display() diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 1c804fb8db..02446ed6f3 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -16,9 +16,13 @@ futures = { workspace = true, features = ["alloc", "std"] } multimap = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_path_to_error = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs"] } toml = { workspace = true } +toml_edit = { workspace = true } +tracing = { workspace = true } [dev-dependencies] anyhow = { workspace = true } diff --git a/codex-rs/core/src/config_loader/diagnostics.rs b/codex-rs/config/src/diagnostics.rs similarity index 90% rename from codex-rs/core/src/config_loader/diagnostics.rs rename to codex-rs/config/src/diagnostics.rs index 64f9c83888..be6e123d33 100644 --- a/codex-rs/core/src/config_loader/diagnostics.rs +++ b/codex-rs/config/src/diagnostics.rs @@ -1,10 +1,12 @@ //! Helpers for mapping config parse/validation failures to file locations and //! rendering them in a user-friendly way. -use crate::config::CONFIG_TOML_FILE; -use crate::config::ConfigToml; +use crate::ConfigLayerEntry; +use crate::ConfigLayerStack; +use crate::ConfigLayerStackOrdering; use codex_app_server_protocol::ConfigLayerSource; use codex_utils_absolute_path::AbsolutePathBufGuard; +use serde::de::DeserializeOwned; use serde_path_to_error::Path as SerdePath; use serde_path_to_error::Segment as SerdeSegment; use std::fmt; @@ -17,10 +19,6 @@ use toml_edit::Item; use toml_edit::Table; use toml_edit::Value; -use super::ConfigLayerEntry; -use super::ConfigLayerStack; -use super::ConfigLayerStackOrdering; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TextPosition { pub line: usize, @@ -88,7 +86,7 @@ impl std::error::Error for ConfigLoadError { } } -pub(crate) fn io_error_from_config_error( +pub fn io_error_from_config_error( kind: io::ErrorKind, error: ConfigError, source: Option, @@ -96,7 +94,7 @@ pub(crate) fn io_error_from_config_error( io::Error::new(kind, ConfigLoadError::new(error, source)) } -pub(crate) fn config_error_from_toml( +pub fn config_error_from_toml( path: impl AsRef, contents: &str, err: toml::de::Error, @@ -108,7 +106,7 @@ pub(crate) fn config_error_from_toml( ConfigError::new(path.as_ref().to_path_buf(), range, err.message()) } -pub(crate) fn config_error_from_config_toml( +pub fn config_error_from_typed_toml( path: impl AsRef, contents: &str, ) -> Option { @@ -117,7 +115,7 @@ pub(crate) fn config_error_from_config_toml( Err(err) => return Some(config_error_from_toml(path, contents, err)), }; - let result: Result = serde_path_to_error::deserialize(deserializer); + let result: Result = serde_path_to_error::deserialize(deserializer); match result { Ok(_) => None, Err(err) => { @@ -136,28 +134,36 @@ pub(crate) fn config_error_from_config_toml( } } -pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option { +pub async fn first_layer_config_error( + layers: &ConfigLayerStack, + config_toml_file: &str, +) -> Option { // When the merged config fails schema validation, we surface the first concrete // per-file error to point users at a specific file and range rather than an // opaque merged-layer failure. - first_layer_config_error_for_entries( + first_layer_config_error_for_entries::( layers.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false), + config_toml_file, ) .await } -pub(crate) async fn first_layer_config_error_from_entries( +pub async fn first_layer_config_error_from_entries( layers: &[ConfigLayerEntry], + config_toml_file: &str, ) -> Option { - first_layer_config_error_for_entries(layers.iter()).await + first_layer_config_error_for_entries::(layers.iter(), config_toml_file).await } -async fn first_layer_config_error_for_entries<'a, I>(layers: I) -> Option +async fn first_layer_config_error_for_entries<'a, T: DeserializeOwned, I>( + layers: I, + config_toml_file: &str, +) -> Option where I: IntoIterator, { for layer in layers { - let Some(path) = config_path_for_layer(layer) else { + let Some(path) = config_path_for_layer(layer, config_toml_file) else { continue; }; let contents = match tokio::fs::read_to_string(&path).await { @@ -174,7 +180,7 @@ where continue; }; let _guard = AbsolutePathBufGuard::new(parent); - if let Some(error) = config_error_from_config_toml(&path, &contents) { + if let Some(error) = config_error_from_typed_toml::(&path, &contents) { return Some(error); } } @@ -182,12 +188,12 @@ where None } -fn config_path_for_layer(layer: &ConfigLayerEntry) -> Option { +fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Option { match &layer.name { ConfigLayerSource::System { file } => Some(file.to_path_buf()), ConfigLayerSource::User { file } => Some(file.to_path_buf()), ConfigLayerSource::Project { dot_codex_folder } => { - Some(dot_codex_folder.as_path().join(CONFIG_TOML_FILE)) + Some(dot_codex_folder.as_path().join(config_toml_file)) } ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()), ConfigLayerSource::Mdm { .. } diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 42f3ad76af..41b9a3ae06 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -1,12 +1,15 @@ mod cloud_requirements; mod config_requirements; mod constraint; +mod diagnostics; mod fingerprint; mod merge; mod overrides; mod requirements_exec_policy; mod state; +pub const CONFIG_TOML_FILE: &str = "config.toml"; + pub use cloud_requirements::CloudRequirementsLoader; pub use config_requirements::ConfigRequirements; pub use config_requirements::ConfigRequirementsToml; @@ -24,6 +27,17 @@ pub use config_requirements::WebSearchModeRequirement; pub use constraint::Constrained; pub use constraint::ConstraintError; pub use constraint::ConstraintResult; +pub use diagnostics::ConfigError; +pub use diagnostics::ConfigLoadError; +pub use diagnostics::TextPosition; +pub use diagnostics::TextRange; +pub use diagnostics::config_error_from_toml; +pub use diagnostics::config_error_from_typed_toml; +pub use diagnostics::first_layer_config_error; +pub use diagnostics::first_layer_config_error_from_entries; +pub use diagnostics::format_config_error; +pub use diagnostics::format_config_error_with_source; +pub use diagnostics::io_error_from_config_error; pub use fingerprint::version_for_toml; pub use merge::merge_toml_values; pub use overrides::build_cli_overrides_layer; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 2b62d96254..bda19d78ea 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -78,7 +78,6 @@ rmcp = { workspace = true, default-features = false, features = [ schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -serde_path_to_error = { workspace = true } serde_yaml = { workspace = true } sha1 = { workspace = true } sha2 = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 90d496973a..6c9c9a678f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -120,7 +120,6 @@ use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::codex_thread::ThreadConfigSnapshot; use crate::compact::collect_user_messages; -use crate::config::CONFIG_TOML_FILE; use crate::config::Config; use crate::config::Constrained; use crate::config::ConstraintResult; @@ -136,6 +135,7 @@ use crate::error::CodexErr; use crate::error::Result as CodexResult; #[cfg(test)] use crate::exec::StreamOutput; +use codex_config::CONFIG_TOML_FILE; #[derive(Debug, PartialEq)] pub enum SteerInputError { diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 07443fa665..0dfb4efd8e 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -1,9 +1,9 @@ -use crate::config::CONFIG_TOML_FILE; use crate::config::types::McpServerConfig; use crate::config::types::Notice; use crate::path_utils::resolve_symlink_write_paths; use crate::path_utils::write_atomically; use anyhow::Context; +use codex_config::CONFIG_TOML_FILE; use codex_protocol::config_types::Personality; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index edaabed1fa..ca3978b73d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -117,8 +117,6 @@ pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = Some(6); pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1; -pub const CONFIG_TOML_FILE: &str = "config.toml"; - #[cfg(test)] pub(crate) fn test_config() -> Config { let codex_home = tempdir().expect("create temp dir"); @@ -2302,6 +2300,7 @@ mod tests { use crate::config::types::Notifications; use crate::config_loader::RequirementSource; use crate::features::Feature; + use codex_config::CONFIG_TOML_FILE; use super::*; use core_test_support::test_absolute_path; diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 1033db728d..da675d2389 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -1,4 +1,3 @@ -use super::CONFIG_TOML_FILE; use super::ConfigToml; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; @@ -26,6 +25,7 @@ use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::OverriddenMetadata; use codex_app_server_protocol::WriteStatus; +use codex_config::CONFIG_TOML_FILE; use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value as JsonValue; use std::borrow::Cow; diff --git a/codex-rs/core/src/config_loader/layer_io.rs b/codex-rs/core/src/config_loader/layer_io.rs index a97f285549..5caebdf527 100644 --- a/codex-rs/core/src/config_loader/layer_io.rs +++ b/codex-rs/core/src/config_loader/layer_io.rs @@ -1,10 +1,10 @@ use super::LoaderOverrides; -use super::diagnostics::config_error_from_toml; -use super::diagnostics::io_error_from_config_error; #[cfg(target_os = "macos")] use super::macos::ManagedAdminConfigLayer; #[cfg(target_os = "macos")] use super::macos::load_managed_admin_config_layer; +use codex_config::config_error_from_toml; +use codex_config::io_error_from_config_error; use codex_utils_absolute_path::AbsolutePathBuf; use std::io; use std::path::Path; diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index f30a627c40..70014c03b8 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -1,4 +1,3 @@ -mod diagnostics; mod layer_io; #[cfg(target_os = "macos")] mod macos; @@ -6,12 +5,12 @@ mod macos; #[cfg(test)] mod tests; -use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigToml; use crate::config::deserialize_config_toml_with_base; use crate::config_loader::layer_io::LoadedConfigLayers; use crate::git_info::resolve_root_git_project_for_trust; use codex_app_server_protocol::ConfigLayerSource; +use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigRequirementsWithSources; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::TrustLevel; @@ -27,9 +26,11 @@ use std::path::PathBuf; use toml::Value as TomlValue; pub use codex_config::CloudRequirementsLoader; +pub use codex_config::ConfigError; pub use codex_config::ConfigLayerEntry; pub use codex_config::ConfigLayerStack; pub use codex_config::ConfigLayerStackOrdering; +pub use codex_config::ConfigLoadError; pub use codex_config::ConfigRequirements; pub use codex_config::ConfigRequirementsToml; pub use codex_config::ConstrainedWithSource; @@ -42,21 +43,17 @@ pub use codex_config::RequirementSource; pub use codex_config::ResidencyRequirement; pub use codex_config::SandboxModeRequirement; pub use codex_config::Sourced; +pub use codex_config::TextPosition; +pub use codex_config::TextRange; pub use codex_config::WebSearchModeRequirement; pub(crate) use codex_config::build_cli_overrides_layer; +pub(crate) use codex_config::config_error_from_toml; +pub use codex_config::format_config_error; +pub use codex_config::format_config_error_with_source; +pub(crate) use codex_config::io_error_from_config_error; pub use codex_config::merge_toml_values; #[cfg(test)] pub(crate) use codex_config::version_for_toml; -pub use diagnostics::ConfigError; -pub use diagnostics::ConfigLoadError; -pub use diagnostics::TextPosition; -pub use diagnostics::TextRange; -pub(crate) use diagnostics::config_error_from_toml; -pub(crate) use diagnostics::first_layer_config_error; -pub(crate) use diagnostics::first_layer_config_error_from_entries; -pub use diagnostics::format_config_error; -pub use diagnostics::format_config_error_with_source; -pub(crate) use diagnostics::io_error_from_config_error; /// On Unix systems, load default settings from this file path, if present. /// Note that /etc/codex/ is treated as a "config folder," so subfolders such @@ -68,6 +65,17 @@ const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData"; const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; +pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option { + codex_config::first_layer_config_error::(layers, CONFIG_TOML_FILE).await +} + +pub(crate) async fn first_layer_config_error_from_entries( + layers: &[ConfigLayerEntry], +) -> Option { + codex_config::first_layer_config_error_from_entries::(layers, CONFIG_TOML_FILE) + .await +} + /// To build up the set of admin-enforced constraints, we build up from multiple /// configuration layers in the following order, but a constraint defined in an /// earlier layer cannot be overridden by a later layer: diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 08bbb5304d..3eab05ba18 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -1,6 +1,5 @@ use super::LoaderOverrides; use super::load_config_layers_state; -use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::ConfigToml; @@ -15,6 +14,7 @@ use crate::config_loader::ConfigRequirementsWithSources; use crate::config_loader::RequirementSource; use crate::config_loader::load_requirements_toml; use crate::config_loader::version_for_toml; +use codex_config::CONFIG_TOML_FILE; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::AskForApproval; @@ -153,7 +153,7 @@ async fn returns_config_error_for_schema_error_in_user_config() { let config_error = config_error_from_io(&err); let _guard = codex_utils_absolute_path::AbsolutePathBufGuard::new(tmp.path()); let expected_config_error = - super::diagnostics::config_error_from_config_toml(&config_path, contents) + codex_config::config_error_from_typed_toml::(&config_path, contents) .expect("schema error"); assert_eq!(config_error, &expected_config_error); } @@ -166,7 +166,7 @@ fn schema_error_points_to_feature_value() { std::fs::write(&config_path, contents).expect("write config"); let _guard = codex_utils_absolute_path::AbsolutePathBufGuard::new(tmp.path()); - let error = super::diagnostics::config_error_from_config_toml(&config_path, contents) + let error = codex_config::config_error_from_typed_toml::(&config_path, contents) .expect("schema error"); let value_line = contents.lines().nth(1).expect("value line"); diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index a4be91046c..4cefe4b216 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -5,13 +5,13 @@ //! booleans through multiple types, call sites consult a single `Features` //! container attached to `Config`. -use crate::config::CONFIG_TOML_FILE; use crate::config::Config; use crate::config::ConfigToml; use crate::config::profile::ConfigProfile; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::WarningEvent; +use codex_config::CONFIG_TOML_FILE; use codex_otel::OtelManager; use schemars::JsonSchema; use serde::Deserialize; diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 1af36b2acb..15d4e41664 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -1,4 +1,3 @@ -use crate::config::CONFIG_TOML_FILE; use crate::config::NetworkToml; use crate::config::PermissionsToml; use crate::config::find_codex_home; @@ -11,6 +10,7 @@ use anyhow::Context; use anyhow::Result; use async_trait::async_trait; use codex_app_server_protocol::ConfigLayerSource; +use codex_config::CONFIG_TOML_FILE; use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigState; use codex_network_proxy::NetworkProxyConfig; diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 3c497bfcf8..1ad231438a 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -806,7 +806,6 @@ fn extract_frontmatter(contents: &str) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::ConfigToml; @@ -817,6 +816,7 @@ mod tests { use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; + use codex_config::CONFIG_TOML_FILE; use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs index 1cb07c1f4e..e5fdf61fdb 100644 --- a/codex-rs/core/tests/suite/unstable_features_warning.rs +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -1,8 +1,8 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] +use codex_config::CONFIG_TOML_FILE; use codex_core::CodexAuth; use codex_core::NewThread; -use codex_core::config::CONFIG_TOML_FILE; use codex_core::features::Feature; use codex_core::protocol::EventMsg; use codex_core::protocol::InitialHistory; From a87c9c3299a451ea0cb96896e36ce619e84f9ee6 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 20 Feb 2026 23:32:59 -0800 Subject: [PATCH 031/282] Collapse waited message (#12430) image --- ...ified_exec_empty_then_non_empty_after.snap | 3 +- ...ified_exec_non_empty_then_empty_after.snap | 3 +- ...d_exec_wait_after_final_agent_message.snap | 3 +- ...ec_wait_before_streamed_agent_message.snap | 3 +- ...ied_exec_waiting_multiple_empty_after.snap | 3 +- codex-rs/tui/src/history_cell.rs | 29 ++++++++++--------- 6 files changed, 21 insertions(+), 23 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_empty_then_non_empty_after.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_empty_then_non_empty_after.snap index 400845c82f..dcfad97ba0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_empty_then_non_empty_after.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_empty_then_non_empty_after.snap @@ -2,8 +2,7 @@ source: tui/src/chatwidget/tests.rs expression: combined --- -↳ Interacted with background terminal · just fix - └ (waited) +• Waited for background terminal · just fix ↳ Interacted with background terminal · just fix └ ls diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap index f6f0188f95..952205e732 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap @@ -5,5 +5,4 @@ expression: combined ↳ Interacted with background terminal · just fix └ pwd -↳ Interacted with background terminal · just fix - └ (waited) +• Waited for background terminal · just fix diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap index 5caf42e1bc..fdbdffc5dd 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap @@ -2,7 +2,6 @@ source: tui/src/chatwidget/tests.rs expression: combined --- -↳ Interacted with background terminal · cargo test -p codex-core - └ (waited) +• Waited for background terminal · cargo test -p codex-core • Final response. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap index cfea4f8a66..f91637e2db 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap @@ -2,7 +2,6 @@ source: tui/src/chatwidget/tests.rs expression: combined --- -↳ Interacted with background terminal · cargo test -p codex-core - └ (waited) +• Waited for background terminal · cargo test -p codex-core • Streaming response. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap index 782ecb1eab..99bf8e2bdc 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap @@ -2,5 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: combined --- -↳ Interacted with background terminal · just fix - └ (waited) +• Waited for background terminal · just fix diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8789196946..8c4d0147df 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -589,8 +589,13 @@ impl HistoryCell for UnifiedExecInteractionCell { return Vec::new(); } let wrap_width = width as usize; + let waited_only = self.stdin.is_empty(); - let mut header_spans = vec!["↳ ".dim(), "Interacted with background terminal".bold()]; + let mut header_spans = if waited_only { + vec!["• Waited for background terminal".bold()] + } else { + vec!["↳ ".dim(), "Interacted with background terminal".bold()] + }; if let Some(command) = &self.command_display && !command.is_empty() { @@ -603,14 +608,15 @@ impl HistoryCell for UnifiedExecInteractionCell { let header_wrapped = word_wrap_line(&header, RtOptions::new(wrap_width)); push_owned_lines(&header_wrapped, &mut out); - let input_lines: Vec> = if self.stdin.is_empty() { - vec![vec!["(waited)".dim()].into()] - } else { - self.stdin - .lines() - .map(|line| Line::from(line.to_string())) - .collect() - }; + if waited_only { + return out; + } + + let input_lines: Vec> = self + .stdin + .lines() + .map(|line| Line::from(line.to_string())) + .collect(); let input_wrapped = word_wrap_lines( input_lines, @@ -2465,10 +2471,7 @@ mod tests { fn unified_exec_interaction_cell_renders_wait() { let cell = new_unified_exec_interaction(None, String::new()); let lines = render_transcript(&cell); - assert_eq!( - lines, - vec!["↳ Interacted with background terminal", " └ (waited)"], - ); + assert_eq!(lines, vec!["• Waited for background terminal"]); } #[test] From 1af2a37ada16b8227ecd1b8e8411d2449d97ec21 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 20 Feb 2026 23:45:35 -0800 Subject: [PATCH 032/282] chore: remove codex-core public protocol/shell re-exports (#12432) ## Why `codex-rs/core/src/lib.rs` re-exported a broad set of types and modules from `codex-protocol` and `codex-shell-command`. That made it easy for workspace crates to import those APIs through `codex-core`, which in turn hides dependency edges and makes it harder to reduce compile-time coupling over time. This change removes those public re-exports so call sites must import from the source crates directly. Even when a crate still depends on `codex-core` today, this makes dependency boundaries explicit and unblocks future work to drop `codex-core` dependencies where possible. ## What Changed - Removed public re-exports from `codex-rs/core/src/lib.rs` for: - `codex_protocol::protocol` and related protocol/model types (including `InitialHistory`) - `codex_protocol::config_types` (`protocol_config_types`) - `codex_shell_command::{bash, is_dangerous_command, is_safe_command, parse_command, powershell}` - Migrated workspace Rust call sites to import directly from: - `codex_protocol::protocol` - `codex_protocol::config_types` - `codex_protocol::models` - `codex_shell_command` - Added explicit `Cargo.toml` dependencies (`codex-protocol` / `codex-shell-command`) in crates that now import those crates directly. - Kept `codex-core` internal modules compiling by using `pub(crate)` aliases in `core/src/lib.rs` (internal-only, not part of the public API). - Updated the two utility crates that can already drop a `codex-core` dependency edge entirely: - `codex-utils-approval-presets` - `codex-utils-cli` ## Verification - `cargo test -p codex-utils-approval-presets` - `cargo test -p codex-utils-cli` - `cargo check --workspace --all-targets` - `just clippy` --- codex-rs/Cargo.lock | 11 +- .../schema/json/ApplyPatchApprovalParams.json | 2 +- .../json/ExecCommandApprovalParams.json | 2 +- .../schema/json/ServerRequest.json | 4 +- .../codex_app_server_protocol.schemas.json | 4 +- .../typescript/ApplyPatchApprovalParams.ts | 4 +- .../typescript/ExecCommandApprovalParams.ts | 4 +- .../app-server-protocol/src/protocol/v1.rs | 8 +- codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/bespoke_event_handling.rs | 46 +++--- .../app-server/src/codex_message_processor.rs | 14 +- .../suite/codex_message_processor_flow.rs | 8 +- codex-rs/app-server/tests/suite/config.rs | 2 +- .../app-server/tests/suite/fork_thread.rs | 2 +- codex-rs/app-server/tests/suite/interrupt.rs | 2 +- .../app-server/tests/suite/list_resume.rs | 2 +- .../app-server/tests/suite/output_schema.rs | 4 +- codex-rs/app-server/tests/suite/v2/review.rs | 2 +- .../app-server/tests/suite/v2/turn_start.rs | 2 +- .../tests/suite/v2/turn_start_zsh_fork.rs | 4 +- codex-rs/cli/src/main.rs | 4 +- codex-rs/cli/src/mcp_cmd.rs | 2 +- codex-rs/core/src/lib.rs | 23 +-- codex-rs/core/tests/common/lib.rs | 13 +- codex-rs/core/tests/common/test_codex.rs | 10 +- codex-rs/core/tests/responses_headers.rs | 4 +- codex-rs/core/tests/suite/abort_tasks.rs | 4 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 8 +- codex-rs/core/tests/suite/approvals.rs | 14 +- codex-rs/core/tests/suite/cli_stream.rs | 2 +- codex-rs/core/tests/suite/client.rs | 16 +- .../core/tests/suite/client_websockets.rs | 10 +- codex-rs/core/tests/suite/codex_delegate.rs | 14 +- .../tests/suite/collaboration_instructions.rs | 8 +- codex-rs/core/tests/suite/compact.rs | 18 +-- codex-rs/core/tests/suite/compact_remote.rs | 12 +- .../core/tests/suite/compact_resume_fork.rs | 6 +- .../core/tests/suite/deprecation_notice.rs | 4 +- codex-rs/core/tests/suite/exec.rs | 2 +- codex-rs/core/tests/suite/exec_policy.rs | 8 +- codex-rs/core/tests/suite/fork_thread.rs | 8 +- codex-rs/core/tests/suite/image_rollout.rs | 12 +- codex-rs/core/tests/suite/items.rs | 20 +-- codex-rs/core/tests/suite/json_result.rs | 8 +- codex-rs/core/tests/suite/live_reload.rs | 8 +- codex-rs/core/tests/suite/model_overrides.rs | 4 +- codex-rs/core/tests/suite/model_switching.rs | 8 +- .../core/tests/suite/model_visible_layout.rs | 8 +- codex-rs/core/tests/suite/models_cache_ttl.rs | 8 +- .../core/tests/suite/models_etag_responses.rs | 8 +- codex-rs/core/tests/suite/override_updates.rs | 16 +- codex-rs/core/tests/suite/pending_input.rs | 4 +- .../core/tests/suite/permissions_messages.rs | 8 +- codex-rs/core/tests/suite/personality.rs | 8 +- codex-rs/core/tests/suite/prompt_caching.rs | 12 +- codex-rs/core/tests/suite/quota_exceeded.rs | 4 +- .../core/tests/suite/realtime_conversation.rs | 20 +-- codex-rs/core/tests/suite/remote_models.rs | 10 +- .../core/tests/suite/request_compression.rs | 4 +- .../core/tests/suite/request_user_input.rs | 8 +- codex-rs/core/tests/suite/resume.rs | 4 +- codex-rs/core/tests/suite/resume_warning.rs | 18 +-- codex-rs/core/tests/suite/review.rs | 28 ++-- codex-rs/core/tests/suite/rmcp_client.rs | 12 +- .../core/tests/suite/rollout_list_find.rs | 2 +- .../tests/suite/safety_check_downgrade.rs | 10 +- codex-rs/core/tests/suite/search_tool.rs | 8 +- codex-rs/core/tests/suite/seatbelt.rs | 2 +- .../core/tests/suite/shell_serialization.rs | 2 +- codex-rs/core/tests/suite/shell_snapshot.rs | 12 +- codex-rs/core/tests/suite/skills.rs | 16 +- .../suite/stream_error_allows_next_turn.rs | 4 +- .../core/tests/suite/stream_no_completed.rs | 4 +- codex-rs/core/tests/suite/tool_harness.rs | 8 +- codex-rs/core/tests/suite/tool_parallelism.rs | 8 +- codex-rs/core/tests/suite/tools.rs | 4 +- codex-rs/core/tests/suite/truncation.rs | 8 +- codex-rs/core/tests/suite/undo.rs | 6 +- codex-rs/core/tests/suite/unified_exec.rs | 10 +- .../tests/suite/unstable_features_warning.rs | 6 +- .../core/tests/suite/user_notification.rs | 4 +- codex-rs/core/tests/suite/user_shell_cmd.rs | 16 +- codex-rs/core/tests/suite/view_image.rs | 8 +- codex-rs/core/tests/suite/web_search.rs | 2 +- .../core/tests/suite/websocket_fallback.rs | 8 +- codex-rs/exec-server/Cargo.toml | 3 + codex-rs/exec-server/src/posix.rs | 2 +- .../exec-server/src/posix/escalate_server.rs | 2 +- codex-rs/exec-server/src/posix/mcp.rs | 2 +- codex-rs/exec-server/tests/common/Cargo.toml | 1 + codex-rs/exec-server/tests/common/lib.rs | 2 +- codex-rs/exec/src/event_processor.rs | 4 +- .../src/event_processor_with_human_output.rs | 76 +++++----- .../src/event_processor_with_jsonl_output.rs | 22 +-- codex-rs/exec/src/lib.rs | 16 +- .../tests/event_processor_with_json_output.rs | 72 ++++----- codex-rs/exec/tests/suite/sandbox.rs | 2 +- codex-rs/linux-sandbox/Cargo.toml | 1 + codex-rs/linux-sandbox/src/bwrap.rs | 6 +- codex-rs/linux-sandbox/src/landlock.rs | 4 +- codex-rs/linux-sandbox/src/linux_run_main.rs | 14 +- .../linux-sandbox/tests/suite/landlock.rs | 4 +- codex-rs/mcp-server/Cargo.toml | 1 + codex-rs/mcp-server/src/codex_tool_config.rs | 2 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 16 +- codex-rs/mcp-server/src/exec_approval.rs | 4 +- codex-rs/mcp-server/src/message_processor.rs | 4 +- codex-rs/mcp-server/src/outgoing_message.rs | 12 +- codex-rs/mcp-server/src/patch_approval.rs | 6 +- codex-rs/mcp-server/tests/suite/codex_tool.rs | 6 +- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/additional_dirs.rs | 6 +- codex-rs/tui/src/app.rs | 48 +++--- codex-rs/tui/src/app_backtrack.rs | 8 +- codex-rs/tui/src/app_event.rs | 10 +- .../tui/src/bottom_pane/approval_overlay.rs | 14 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 +- .../src/bottom_pane/chat_composer_history.rs | 4 +- codex-rs/tui/src/bottom_pane/feedback_view.rs | 2 +- codex-rs/tui/src/bottom_pane/mod.rs | 2 +- .../src/bottom_pane/request_user_input/mod.rs | 2 +- .../tui/src/bottom_pane/skills_toggle_view.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 122 +++++++-------- codex-rs/tui/src/chatwidget/agent.rs | 16 +- codex-rs/tui/src/chatwidget/interrupts.rs | 14 +- codex-rs/tui/src/chatwidget/skills.rs | 6 +- codex-rs/tui/src/chatwidget/tests.rs | 141 +++++++++--------- codex-rs/tui/src/debug_config.rs | 6 +- codex-rs/tui/src/diff_render.rs | 2 +- codex-rs/tui/src/exec_cell/model.rs | 2 +- codex-rs/tui/src/exec_cell/render.rs | 6 +- codex-rs/tui/src/exec_command.rs | 2 +- codex-rs/tui/src/history_cell.rs | 16 +- codex-rs/tui/src/lib.rs | 16 +- codex-rs/tui/src/main.rs | 5 +- codex-rs/tui/src/multi_agents.rs | 20 +-- codex-rs/tui/src/pager_overlay.rs | 6 +- codex-rs/tui/src/session_log.rs | 2 +- codex-rs/tui/src/status/card.rs | 10 +- codex-rs/tui/src/status/rate_limits.rs | 6 +- codex-rs/tui/src/status/tests.rs | 14 +- codex-rs/tui/src/status_indicator_widget.rs | 2 +- codex-rs/utils/approval-presets/Cargo.toml | 2 +- codex-rs/utils/approval-presets/src/lib.rs | 4 +- codex-rs/utils/cli/Cargo.toml | 1 - .../utils/cli/src/approval_mode_cli_arg.rs | 2 +- .../utils/cli/src/sandbox_mode_cli_arg.rs | 2 +- codex-rs/utils/sandbox-summary/Cargo.toml | 1 + .../sandbox-summary/src/sandbox_summary.rs | 4 +- 149 files changed, 769 insertions(+), 752 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4ef13ad845..e9fd6ed167 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1309,6 +1309,7 @@ dependencies = [ "codex-login", "codex-protocol", "codex-rmcp-client", + "codex-shell-command", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-cli", @@ -1773,6 +1774,8 @@ dependencies = [ "clap", "codex-core", "codex-execpolicy", + "codex-protocol", + "codex-shell-command", "codex-utils-cargo-bin", "exec_server_test_support", "libc", @@ -1910,6 +1913,7 @@ dependencies = [ "cc", "clap", "codex-core", + "codex-protocol", "codex-utils-absolute-path", "landlock", "libc", @@ -1966,6 +1970,7 @@ dependencies = [ "codex-arg0", "codex-core", "codex-protocol", + "codex-shell-command", "codex-utils-cli", "codex-utils-json-to-toml", "core_test_support", @@ -2240,6 +2245,7 @@ dependencies = [ "codex-login", "codex-otel", "codex-protocol", + "codex-shell-command", "codex-state", "codex-utils-absolute-path", "codex-utils-approval-presets", @@ -2320,7 +2326,7 @@ dependencies = [ name = "codex-utils-approval-presets" version = "0.0.0" dependencies = [ - "codex-core", + "codex-protocol", ] [[package]] @@ -2346,7 +2352,6 @@ name = "codex-utils-cli" version = "0.0.0" dependencies = [ "clap", - "codex-core", "codex-protocol", "pretty_assertions", "serde", @@ -2439,6 +2444,7 @@ name = "codex-utils-sandbox-summary" version = "0.0.0" dependencies = [ "codex-core", + "codex-protocol", "codex-utils-absolute-path", "pretty_assertions", ] @@ -3540,6 +3546,7 @@ version = "0.0.0" dependencies = [ "anyhow", "codex-core", + "codex-protocol", "codex-utils-cargo-bin", "rmcp", "serde_json", diff --git a/codex-rs/app-server-protocol/schema/json/ApplyPatchApprovalParams.json b/codex-rs/app-server-protocol/schema/json/ApplyPatchApprovalParams.json index da271c75ad..d1174a05b0 100644 --- a/codex-rs/app-server-protocol/schema/json/ApplyPatchApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/ApplyPatchApprovalParams.json @@ -77,7 +77,7 @@ }, "properties": { "callId": { - "description": "Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] and [codex_core::protocol::PatchApplyEndEvent].", + "description": "Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent] and [codex_protocol::protocol::PatchApplyEndEvent].", "type": "string" }, "conversationId": { diff --git a/codex-rs/app-server-protocol/schema/json/ExecCommandApprovalParams.json b/codex-rs/app-server-protocol/schema/json/ExecCommandApprovalParams.json index 3855168e09..43f85d21ae 100644 --- a/codex-rs/app-server-protocol/schema/json/ExecCommandApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/ExecCommandApprovalParams.json @@ -125,7 +125,7 @@ ] }, "callId": { - "description": "Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] and [codex_core::protocol::ExecCommandEndEvent].", + "description": "Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent] and [codex_protocol::protocol::ExecCommandEndEvent].", "type": "string" }, "command": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 865912058e..553f189dc6 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -4,7 +4,7 @@ "ApplyPatchApprovalParams": { "properties": { "callId": { - "description": "Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] and [codex_core::protocol::PatchApplyEndEvent].", + "description": "Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent] and [codex_protocol::protocol::PatchApplyEndEvent].", "type": "string" }, "conversationId": { @@ -290,7 +290,7 @@ ] }, "callId": { - "description": "Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] and [codex_core::protocol::ExecCommandEndEvent].", + "description": "Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent] and [codex_protocol::protocol::ExecCommandEndEvent].", "type": "string" }, "command": { 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 89f7dc34c3..d478bb2c78 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 @@ -93,7 +93,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "callId": { - "description": "Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] and [codex_core::protocol::PatchApplyEndEvent].", + "description": "Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent] and [codex_protocol::protocol::PatchApplyEndEvent].", "type": "string" }, "conversationId": { @@ -4247,7 +4247,7 @@ ] }, "callId": { - "description": "Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] and [codex_core::protocol::ExecCommandEndEvent].", + "description": "Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent] and [codex_protocol::protocol::ExecCommandEndEvent].", "type": "string" }, "command": { diff --git a/codex-rs/app-server-protocol/schema/typescript/ApplyPatchApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/ApplyPatchApprovalParams.ts index 27de027cc6..d04f7b75df 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ApplyPatchApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ApplyPatchApprovalParams.ts @@ -6,8 +6,8 @@ import type { ThreadId } from "./ThreadId"; export type ApplyPatchApprovalParams = { conversationId: ThreadId, /** - * Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] - * and [codex_core::protocol::PatchApplyEndEvent]. + * Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent] + * and [codex_protocol::protocol::PatchApplyEndEvent]. */ callId: string, fileChanges: { [key in string]?: FileChange }, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/ExecCommandApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/ExecCommandApprovalParams.ts index 5637d1305a..f68872905d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ExecCommandApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ExecCommandApprovalParams.ts @@ -6,8 +6,8 @@ import type { ThreadId } from "./ThreadId"; export type ExecCommandApprovalParams = { conversationId: ThreadId, /** - * Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] - * and [codex_core::protocol::ExecCommandEndEvent]. + * Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent] + * and [codex_protocol::protocol::ExecCommandEndEvent]. */ callId: string, /** diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 13308ffafe..976f4e69d8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -234,8 +234,8 @@ pub struct GitDiffToRemoteResponse { #[serde(rename_all = "camelCase")] pub struct ApplyPatchApprovalParams { pub conversation_id: ThreadId, - /// Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] - /// and [codex_core::protocol::PatchApplyEndEvent]. + /// Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent] + /// and [codex_protocol::protocol::PatchApplyEndEvent]. pub call_id: String, pub file_changes: HashMap, /// Optional explanatory reason (e.g. request for extra write access). @@ -255,8 +255,8 @@ pub struct ApplyPatchApprovalResponse { #[serde(rename_all = "camelCase")] pub struct ExecCommandApprovalParams { pub conversation_id: ThreadId, - /// Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] - /// and [codex_core::protocol::ExecCommandEndEvent]. + /// Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent] + /// and [codex_protocol::protocol::ExecCommandEndEvent]. pub call_id: String, /// Identifier for this specific approval callback. pub approval_id: Option, diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 7b90affe69..2f273a6794 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -21,6 +21,7 @@ async-trait = { workspace = true } codex-arg0 = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } +codex-shell-command = { workspace = true } codex-utils-cli = { workspace = true } codex-backend-client = { workspace = true } codex-file-search = { workspace = true } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f53b10b843..fe3eb96852 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -76,28 +76,28 @@ use codex_app_server_protocol::convert_patch_changes; use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::find_thread_name_by_id; -use codex_core::parse_command::shlex_join; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::CodexErrorInfo as CoreCodexErrorInfo; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::Op; -use codex_core::protocol::ReviewDecision; -use codex_core::protocol::TokenCountEvent; -use codex_core::protocol::TurnDiffEvent; use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::McpToolCallBeginEvent; +use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; +use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; +use codex_shell_command::parse_command::shlex_join; use std::collections::HashMap; use std::convert::TryFrom; use std::path::Path; @@ -1942,7 +1942,7 @@ async fn on_command_execution_request_approval_response( } fn collab_resume_begin_item( - begin_event: codex_core::protocol::CollabResumeBeginEvent, + begin_event: codex_protocol::protocol::CollabResumeBeginEvent, ) -> ThreadItem { ThreadItem::CollabAgentToolCall { id: begin_event.call_id, @@ -1955,7 +1955,7 @@ fn collab_resume_begin_item( } } -fn collab_resume_end_item(end_event: codex_core::protocol::CollabResumeEndEvent) -> ThreadItem { +fn collab_resume_end_item(end_event: codex_protocol::protocol::CollabResumeEndEvent) -> ThreadItem { let status = match &end_event.status { codex_protocol::protocol::AgentStatus::Errored(_) | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, @@ -2060,17 +2060,17 @@ mod tests { use anyhow::anyhow; use anyhow::bail; use codex_app_server_protocol::TurnPlanStepStatus; - use codex_core::protocol::CollabResumeBeginEvent; - use codex_core::protocol::CollabResumeEndEvent; - use codex_core::protocol::CreditsSnapshot; - use codex_core::protocol::McpInvocation; - use codex_core::protocol::RateLimitSnapshot; - use codex_core::protocol::RateLimitWindow; - use codex_core::protocol::TokenUsage; - use codex_core::protocol::TokenUsageInfo; use codex_protocol::mcp::CallToolResult; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; + use codex_protocol::protocol::CollabResumeBeginEvent; + use codex_protocol::protocol::CollabResumeEndEvent; + use codex_protocol::protocol::CreditsSnapshot; + use codex_protocol::protocol::McpInvocation; + use codex_protocol::protocol::RateLimitSnapshot; + use codex_protocol::protocol::RateLimitWindow; + use codex_protocol::protocol::TokenUsage; + use codex_protocol::protocol::TokenUsageInfo; use pretty_assertions::assert_eq; use rmcp::model::Content; use serde_json::Value as JsonValue; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 17a67fa4dc..d28b79db7e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -175,7 +175,6 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::CodexThread; use codex_core::Cursor as RolloutCursor; -use codex_core::InitialHistory; use codex_core::NewThread; use codex_core::RolloutRecorder; use codex_core::SessionMeta; @@ -210,12 +209,6 @@ use codex_core::git_info::git_diff_to_remote; use codex_core::mcp::collect_mcp_snapshot; use codex_core::mcp::group_tools_by_server; use codex_core::parse_cursor; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::ReviewDelivery as CoreReviewDelivery; -use codex_core::protocol::ReviewRequest; -use codex_core::protocol::ReviewTarget as CoreReviewTarget; -use codex_core::protocol::SessionConfiguredEvent; use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; use codex_core::rollout_date_parts; @@ -240,13 +233,20 @@ use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GitInfo as CoreGitInfo; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; use codex_protocol::protocol::McpServerRefreshConfig; +use codex_protocol::protocol::Op; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RemoteSkillHazelnutScope; use codex_protocol::protocol::RemoteSkillProductSurface; +use codex_protocol::protocol::ReviewDelivery as CoreReviewDelivery; +use codex_protocol::protocol::ReviewRequest; +use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::user_input::UserInput as CoreInputItem; diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index e6f887dba0..f6d3b9efb9 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -21,15 +21,15 @@ use codex_app_server_protocol::SendUserMessageResponse; use codex_app_server_protocol::SendUserTurnParams; use codex_app_server_protocol::SendUserTurnResponse; use codex_app_server_protocol::ServerRequest; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::ReasoningSummary; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::env; use std::path::Path; @@ -312,7 +312,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> { // Approve so the first turn can complete mcp.send_response( request_id, - serde_json::json!({ "decision": codex_core::protocol::ReviewDecision::Approved }), + serde_json::json!({ "decision": codex_protocol::protocol::ReviewDecision::Approved }), ) .await?; diff --git a/codex-rs/app-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs index 84b268a3c2..27a44ef550 100644 --- a/codex-rs/app-server/tests/suite/config.rs +++ b/codex-rs/app-server/tests/suite/config.rs @@ -9,12 +9,12 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxSettings; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; -use codex_core::protocol::AskForApproval; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::path::Path; diff --git a/codex-rs/app-server/tests/suite/fork_thread.rs b/codex-rs/app-server/tests/suite/fork_thread.rs index 17548fe041..ec282be4a1 100644 --- a/codex-rs/app-server/tests/suite/fork_thread.rs +++ b/codex-rs/app-server/tests/suite/fork_thread.rs @@ -10,7 +10,7 @@ use codex_app_server_protocol::NewConversationParams; // reused for overrides sh use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionConfiguredNotification; -use codex_core::protocol::EventMsg; +use codex_protocol::protocol::EventMsg; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/interrupt.rs b/codex-rs/app-server/tests/suite/interrupt.rs index 2270afce28..dd09b832f8 100644 --- a/codex-rs/app-server/tests/suite/interrupt.rs +++ b/codex-rs/app-server/tests/suite/interrupt.rs @@ -12,7 +12,7 @@ use codex_app_server_protocol::NewConversationResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SendUserMessageParams; use codex_app_server_protocol::SendUserMessageResponse; -use codex_core::protocol::TurnAbortReason; +use codex_protocol::protocol::TurnAbortReason; use core_test_support::skip_if_no_network; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs index efa90ea351..97c8f41d11 100644 --- a/codex-rs/app-server/tests/suite/list_resume.rs +++ b/codex-rs/app-server/tests/suite/list_resume.rs @@ -12,9 +12,9 @@ use codex_app_server_protocol::ResumeConversationParams; use codex_app_server_protocol::ResumeConversationResponse; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionConfiguredNotification; -use codex_core::protocol::EventMsg; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::EventMsg; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/output_schema.rs b/codex-rs/app-server/tests/suite/output_schema.rs index c120a7fe2d..3d83bec7dd 100644 --- a/codex-rs/app-server/tests/suite/output_schema.rs +++ b/codex-rs/app-server/tests/suite/output_schema.rs @@ -9,10 +9,10 @@ use codex_app_server_protocol::NewConversationResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SendUserTurnParams; use codex_app_server_protocol::SendUserTurnResponse; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 219812d6b1..262b13969e 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -209,7 +209,7 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() -> mcp.send_response( request_id, - serde_json::json!({ "decision": codex_core::protocol::ReviewDecision::Approved }), + serde_json::json!({ "decision": codex_protocol::protocol::ReviewDecision::Approved }), ) .await?; timeout( diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 8d6db34731..eaa017d6ae 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -39,10 +39,10 @@ use codex_core::config::ConfigToml; use codex_core::features::FEATURES; use codex_core::features::Feature; use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME; -use codex_core::protocol_config_types::ReasoningSummary; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; use core_test_support::responses; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 4be127dff8..94467b8719 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -111,7 +111,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), model: Some("mock-model".to_string()), effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), - summary: Some(codex_core::protocol_config_types::ReasoningSummary::Auto), + summary: Some(codex_protocol::config_types::ReasoningSummary::Auto), ..Default::default() }) .await?; @@ -505,7 +505,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() }), model: Some("mock-model".to_string()), effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), - summary: Some(codex_core::protocol_config_types::ReasoningSummary::Auto), + summary: Some(codex_protocol::config_types::ReasoningSummary::Auto), ..Default::default() }) .await?; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 98a5ab0779..e5478f1b90 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -390,7 +390,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec TuiCli { diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index be3678e07e..396e077411 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -14,7 +14,7 @@ use codex_core::config::types::McpServerTransportConfig; use codex_core::mcp::auth::McpOAuthLoginSupport; use codex_core::mcp::auth::compute_auth_statuses; use codex_core::mcp::auth::oauth_login_support; -use codex_core::protocol::McpAuthStatus; +use codex_protocol::protocol::McpAuthStatus; use codex_rmcp_client::delete_oauth_tokens; use codex_rmcp_client::perform_oauth_login; use codex_utils_cli::CliConfigOverrides; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 4dc2a607c6..b54830a539 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -84,7 +84,6 @@ mod seatbelt_permissions; mod thread_manager; pub mod web_search; pub mod windows_sandbox_read_grants; -pub use codex_protocol::protocol::InitialHistory; pub use thread_manager::NewThread; pub use thread_manager::ThreadManager; #[deprecated(note = "use ThreadManager")] @@ -138,11 +137,12 @@ mod state; mod tasks; mod user_shell_command; pub mod util; -pub use codex_shell_command::bash; -pub use codex_shell_command::is_dangerous_command; -pub use codex_shell_command::is_safe_command; -pub use codex_shell_command::parse_command; -pub use codex_shell_command::powershell; +pub(crate) use codex_protocol::protocol; +pub(crate) use codex_shell_command::bash; +pub(crate) use codex_shell_command::is_dangerous_command; +pub(crate) use codex_shell_command::is_safe_command; +pub(crate) use codex_shell_command::parse_command; +pub(crate) use codex_shell_command::powershell; pub use apply_patch::CODEX_APPLY_PATCH_ARG1; pub use client::X_CODEX_TURN_METADATA_HEADER; @@ -155,12 +155,6 @@ pub use safety::get_platform_sandbox; pub use tools::spec::parse_tool_input_schema; pub use turn_metadata::build_turn_metadata_header; pub use zsh_exec_bridge::maybe_run_zsh_exec_wrapper_mode; -// Re-export the protocol types from the standalone `codex-protocol` crate so existing -// `codex_core::protocol::...` references continue to work across the workspace. -pub use codex_protocol::protocol; -// Re-export protocol config enums to ensure call sites can use the same types -// as those in the protocol crate when constructing protocol messages. -pub use codex_protocol::config_types as protocol_config_types; pub use client::ModelClient; pub use client::ModelClientSession; @@ -168,11 +162,6 @@ pub use client_common::Prompt; pub use client_common::REVIEW_PROMPT; pub use client_common::ResponseEvent; pub use client_common::ResponseStream; -pub use codex_protocol::models::ContentItem; -pub use codex_protocol::models::LocalShellAction; -pub use codex_protocol::models::LocalShellExecAction; -pub use codex_protocol::models::LocalShellStatus; -pub use codex_protocol::models::ResponseItem; pub use compact::content_items_to_text; pub use event_mapping::parse_turn_item; pub mod compact; diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index a7fda23cd0..a4422e4580 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -175,9 +175,12 @@ pub fn load_sse_fixture_with_id_from_str(raw: &str, id: &str) -> String { .collect() } -pub async fn wait_for_event(codex: &CodexThread, predicate: F) -> codex_core::protocol::EventMsg +pub async fn wait_for_event( + codex: &CodexThread, + predicate: F, +) -> codex_protocol::protocol::EventMsg where - F: FnMut(&codex_core::protocol::EventMsg) -> bool, + F: FnMut(&codex_protocol::protocol::EventMsg) -> bool, { use tokio::time::Duration; wait_for_event_with_timeout(codex, predicate, Duration::from_secs(1)).await @@ -185,7 +188,7 @@ where pub async fn wait_for_event_match(codex: &CodexThread, matcher: F) -> T where - F: Fn(&codex_core::protocol::EventMsg) -> Option, + F: Fn(&codex_protocol::protocol::EventMsg) -> Option, { let ev = wait_for_event(codex, |ev| matcher(ev).is_some()).await; matcher(&ev).unwrap() @@ -195,9 +198,9 @@ pub async fn wait_for_event_with_timeout( codex: &CodexThread, mut predicate: F, wait_time: tokio::time::Duration, -) -> codex_core::protocol::EventMsg +) -> codex_protocol::protocol::EventMsg where - F: FnMut(&codex_core::protocol::EventMsg) -> bool, + F: FnMut(&codex_protocol::protocol::EventMsg) -> bool, { use tokio::time::Duration; use tokio::time::timeout; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index b06aa6c58e..8205909238 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -11,12 +11,12 @@ use codex_core::ThreadManager; use codex_core::built_in_model_providers; use codex_core::config::Config; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::user_input::UserInput; use serde_json::Value; use tempfile::TempDir; diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 71c8b4717a..b392081eeb 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -2,17 +2,17 @@ use std::process::Command; use std::sync::Arc; use codex_core::CodexAuth; -use codex_core::ContentItem; use codex_core::ModelClient; use codex_core::ModelProviderInfo; use codex_core::Prompt; use codex_core::ResponseEvent; -use codex_core::ResponseItem; use codex_core::WireApi; use codex_otel::OtelManager; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use core_test_support::load_default_config_for_test; diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index 53b5a26b02..af3c70b74e 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -2,8 +2,8 @@ use assert_matches::assert_matches; use std::sync::Arc; use std::time::Duration; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index b84df343d0..9ef2715d8c 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -11,11 +11,11 @@ use std::sync::atomic::AtomicI32; use std::sync::atomic::Ordering; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::responses::ev_apply_patch_function_call; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 374cc1ada7..38ae062ab2 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -3,16 +3,16 @@ use anyhow::Result; use codex_core::config::Constrained; use codex_core::features::Feature; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecPolicyAmendment; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_core::sandboxing::SandboxPermissions; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::ExecPolicyAmendment; +use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index d677d96edb..07faed70a8 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -1,6 +1,6 @@ use assert_cmd::Command as AssertCommand; use codex_core::auth::CODEX_API_KEY_ENV_VAR; -use codex_core::protocol::GitInfo; +use codex_protocol::protocol::GitInfo; use codex_utils_cargo_bin::find_resource; use core_test_support::fs_wait; use core_test_support::responses; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cc5fee3066..77a2ba33e0 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,14 +1,9 @@ use codex_core::CodexAuth; -use codex_core::ContentItem; -use codex_core::LocalShellAction; -use codex_core::LocalShellExecAction; -use codex_core::LocalShellStatus; use codex_core::ModelClient; use codex_core::ModelProviderInfo; use codex_core::NewThread; use codex_core::Prompt; use codex_core::ResponseEvent; -use codex_core::ResponseItem; use codex_core::ThreadManager; use codex_core::WireApi; use codex_core::auth::AuthCredentialsStoreMode; @@ -16,9 +11,6 @@ use codex_core::built_in_model_providers; use codex_core::default_client::originator; use codex_core::error::CodexErr; use codex_core::features::Feature; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SessionSource; use codex_otel::OtelManager; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; @@ -27,12 +19,20 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::config_types::Verbosity; +use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::LocalShellAction; +use codex_protocol::models::LocalShellExecAction; +use codex_protocol::models::LocalShellStatus; use codex_protocol::models::MessagePhase; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; +use codex_protocol::models::ResponseItem; use codex_protocol::models::WebSearchAction; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::load_default_config_for_test; diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 736e5f50fe..54e197e568 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1,18 +1,13 @@ #![allow(clippy::expect_used, clippy::unwrap_used)] use codex_core::CodexAuth; -use codex_core::ContentItem; use codex_core::ModelClient; use codex_core::ModelClientSession; use codex_core::ModelProviderInfo; use codex_core::Prompt; use codex_core::ResponseEvent; -use codex_core::ResponseItem; use codex_core::WireApi; use codex_core::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; use codex_core::features::Feature; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SessionSource; use codex_otel::OtelManager; use codex_otel::TelemetryAuthMode; use codex_otel::metrics::MetricsClient; @@ -21,8 +16,13 @@ use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::BaseInstructions; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::responses::WebSocketConnectionConfig; diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index 841dbbb2cb..e71d99b620 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -1,12 +1,12 @@ use codex_core::config::Constrained; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::ReviewDecision; -use codex_core::protocol::ReviewRequest; -use codex_core::protocol::ReviewTarget; -use codex_core::protocol::SandboxPolicy; use codex_core::sandboxing::SandboxPermissions; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::ReviewRequest; +use codex_protocol::protocol::ReviewTarget; +use codex_protocol::protocol::SandboxPolicy; use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index a6cad3a5ce..68d695325d 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -1,11 +1,11 @@ use anyhow::Result; -use codex_core::protocol::COLLABORATION_MODE_CLOSE_TAG; -use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; +use codex_protocol::protocol::COLLABORATION_MODE_CLOSE_TAG; +use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index c4745ab20e..9e9ce931e9 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -5,19 +5,19 @@ use codex_core::built_in_model_providers; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::compact::SUMMARY_PREFIX; use codex_core::config::Config; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ItemCompletedEvent; -use codex_core::protocol::ItemStartedEvent; -use codex_core::protocol::Op; -use codex_core::protocol::RolloutItem; -use codex_core::protocol::RolloutLine; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol::WarningEvent; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::items::TurnItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::ItemStartedEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 6baccc16f4..1d450f9b11 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -6,15 +6,15 @@ use std::path::PathBuf; use anyhow::Result; use codex_core::CodexAuth; use codex_core::compact::SUMMARY_PREFIX; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ItemCompletedEvent; -use codex_core::protocol::ItemStartedEvent; -use codex_core::protocol::Op; -use codex_core::protocol::RolloutItem; -use codex_core::protocol::RolloutLine; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::ItemStartedEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index b66edb84aa..5b3f409125 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -14,10 +14,10 @@ use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::config::Config; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::WarningEvent; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use core_test_support::responses::ResponseMock; use core_test_support::responses::ev_assistant_message; diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index dbae99006d..2a7563bea1 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -7,8 +7,8 @@ use codex_core::config_loader::ConfigLayerStack; use codex_core::config_loader::ConfigRequirements; use codex_core::config_loader::ConfigRequirementsToml; use codex_core::features::Feature; -use codex_core::protocol::DeprecationNoticeEvent; -use codex_core::protocol::EventMsg; +use codex_protocol::protocol::DeprecationNoticeEvent; +use codex_protocol::protocol::EventMsg; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_absolute_path; diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index c3cb15875b..6546600681 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -7,10 +7,10 @@ use codex_core::exec::ExecParams; use codex_core::exec::ExecToolCallOutput; use codex_core::exec::SandboxType; use codex_core::exec::process_exec_tool_call; -use codex_core::protocol::SandboxPolicy; use codex_core::sandboxing::SandboxPermissions; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::protocol::SandboxPolicy; use tempfile::TempDir; use codex_core::error::Result; diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 4922fcf9c8..3fd06d83c7 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -2,14 +2,14 @@ use anyhow::Result; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 3d97400a97..96457065de 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -1,10 +1,10 @@ use codex_core::NewThread; use codex_core::parse_turn_item; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::RolloutItem; -use codex_core::protocol::RolloutLine; use codex_protocol::items::TurnItem; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 691531e049..2349d9f5fa 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -1,13 +1,13 @@ use anyhow::Context; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::RolloutItem; -use codex_core::protocol::RolloutLine; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 842122dfea..844c55c6bc 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -1,16 +1,16 @@ #![cfg(not(target_os = "windows"))] use anyhow::Ok; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ItemCompletedEvent; -use codex_core::protocol::ItemStartedEvent; -use codex_core::protocol::Op; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::TurnItem; use codex_protocol::models::WebSearchAction; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::ItemStartedEvent; +use codex_protocol::protocol::Op; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; @@ -373,8 +373,8 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< }], final_output_json_schema: None, cwd: std::env::current_dir()?, - approval_policy: codex_core::protocol::AskForApproval::Never, - sandbox_policy: codex_core::protocol::SandboxPolicy::DangerFullAccess, + approval_policy: codex_protocol::protocol::AskForApproval::Never, + sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -448,8 +448,8 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { }], final_output_json_schema: None, cwd: std::env::current_dir()?, - approval_policy: codex_core::protocol::AskForApproval::Never, - sandbox_policy: codex_core::protocol::SandboxPolicy::DangerFullAccess, + approval_policy: codex_protocol::protocol::AskForApproval::Never, + sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -544,8 +544,8 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { }], final_output_json_schema: None, cwd: std::env::current_dir()?, - approval_policy: codex_core::protocol::AskForApproval::Never, - sandbox_policy: codex_core::protocol::SandboxPolicy::DangerFullAccess, + approval_policy: codex_protocol::protocol::AskForApproval::Never, + sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index b76d7ac899..e32865e0c7 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -1,10 +1,10 @@ #![cfg(not(target_os = "windows"))] -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::skip_if_no_network; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 0ebcb332ef..3ea0a3cbe9 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -7,12 +7,12 @@ use std::time::Duration; use anyhow::Result; use codex_core::config::ProjectConfig; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::TrustLevel; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ResponsesRequest; diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index 698ee15b61..1ba469f7f9 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -1,6 +1,6 @@ -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4d4abafeb6..6e06b0f224 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -3,10 +3,6 @@ use codex_core::CodexAuth; use codex_core::config::types::Personality; use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; @@ -17,6 +13,10 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_response_created; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index f3daf06c8b..3288eb5c05 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -6,11 +6,11 @@ use std::sync::Arc; use anyhow::Result; use codex_core::config::types::Personality; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index e9f0a73f98..3422e397b8 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -7,9 +7,6 @@ use chrono::TimeZone; use chrono::Utc; use codex_core::CodexAuth; use codex_core::models_manager::manager::RefreshStrategy; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; @@ -19,6 +16,9 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; @@ -95,7 +95,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { }], final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), - approval_policy: codex_core::protocol::AskForApproval::Never, + approval_policy: codex_protocol::protocol::AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 13c31cd7f9..7c40bec987 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -4,12 +4,12 @@ use std::sync::Arc; use anyhow::Result; use codex_core::CodexAuth; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index d8455824b7..7ae78891f1 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -1,13 +1,13 @@ use anyhow::Result; use codex_core::config::Constrained; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::COLLABORATION_MODE_CLOSE_TAG; -use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::RolloutItem; -use codex_core::protocol::RolloutLine; -use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::COLLABORATION_MODE_CLOSE_TAG; +use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index c89bcb9a90..30b0d9d50b 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -1,5 +1,5 @@ -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_completed; diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 5db3bf9f0a..4bbe5f4ab2 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -1,11 +1,11 @@ use anyhow::Result; use codex_core::config::Constrained; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_execpolicy::Policy; use codex_protocol::models::DeveloperInstructions; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses::ev_completed; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index d15147ed36..0b4d485cfb 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -2,10 +2,6 @@ use codex_core::config::types::Personality; use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_core::models_manager::manager::RefreshStrategy; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; @@ -17,6 +13,10 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::responses::mount_models_once; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 351d1d5752..38a8145d50 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -2,19 +2,19 @@ use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::ReasoningSummary; use codex_core::shell::Shell; use codex_core::shell::default_user_shell; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses::ev_completed; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index afa23275e0..466680e6a8 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 3d94ccf533..466686788d 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -1,14 +1,14 @@ use anyhow::Result; -use codex_core::protocol::CodexErrorInfo; -use codex_core::protocol::ConversationAudioParams; -use codex_core::protocol::ConversationStartParams; -use codex_core::protocol::ConversationTextParams; -use codex_core::protocol::ErrorEvent; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::RealtimeAudioFrame; -use codex_core::protocol::RealtimeConversationRealtimeEvent; -use codex_core::protocol::RealtimeEvent; +use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::ConversationAudioParams; +use codex_protocol::protocol::ConversationStartParams; +use codex_protocol::protocol::ConversationTextParams; +use codex_protocol::protocol::ErrorEvent; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RealtimeAudioFrame; +use codex_protocol::protocol::RealtimeConversationRealtimeEvent; +use codex_protocol::protocol::RealtimeEvent; use core_test_support::responses::start_websocket_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 110150d447..f4d0d1b843 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -9,11 +9,6 @@ use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; use codex_core::models_manager::manager::ModelsManager; use codex_core::models_manager::manager::RefreshStrategy; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecCommandSource; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; @@ -24,6 +19,11 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandSource; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index 9a99b913fd..9942435048 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -2,8 +2,8 @@ use codex_core::CodexAuth; use codex_core::features::Feature; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 6d8b8cb035..ac6c1d2f3f 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -3,14 +3,14 @@ use std::collections::HashMap; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 261ec58089..3d75879e49 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 89a08b9e55..b0503d5929 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -2,17 +2,17 @@ use codex_core::CodexAuth; use codex_core::NewThread; -use codex_core::protocol::EventMsg; -use codex_core::protocol::InitialHistory; -use codex_core::protocol::ResumedHistory; -use codex_core::protocol::RolloutItem; -use codex_core::protocol::TurnCompleteEvent; -use codex_core::protocol::TurnContextItem; -use codex_core::protocol::TurnStartedEvent; -use codex_core::protocol::UserMessageEvent; -use codex_core::protocol::WarningEvent; use codex_protocol::ThreadId; use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::ResumedHistory; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::protocol::TurnContextItem; +use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::protocol::UserMessageEvent; +use codex_protocol::protocol::WarningEvent; use core::time::Duration; use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 113776b81f..a123981444 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -1,21 +1,21 @@ use codex_core::CodexThread; -use codex_core::ContentItem; use codex_core::REVIEW_PROMPT; -use codex_core::ResponseItem; use codex_core::config::Config; -use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExitedReviewModeEvent; -use codex_core::protocol::Op; -use codex_core::protocol::ReviewCodeLocation; -use codex_core::protocol::ReviewFinding; -use codex_core::protocol::ReviewLineRange; -use codex_core::protocol::ReviewOutputEvent; -use codex_core::protocol::ReviewRequest; -use codex_core::protocol::ReviewTarget; -use codex_core::protocol::RolloutItem; -use codex_core::protocol::RolloutLine; use codex_core::review_format::render_review_output_text; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExitedReviewModeEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewCodeLocation; +use codex_protocol::protocol::ReviewFinding; +use codex_protocol::protocol::ReviewLineRange; +use codex_protocol::protocol::ReviewOutputEvent; +use codex_protocol::protocol::ReviewRequest; +use codex_protocol::protocol::ReviewTarget; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; use core_test_support::load_sse_fixture_with_id_from_str; use core_test_support::responses::ResponseMock; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4c8cd563a6..df389c5ef7 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -13,12 +13,6 @@ use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; use codex_core::models_manager::manager::RefreshStrategy; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::McpInvocation; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; @@ -27,6 +21,12 @@ use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::McpToolCallBeginEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use codex_utils_cargo_bin::cargo_bin; use core_test_support::responses; diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index d9f92b85bd..83350a0d02 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -11,9 +11,9 @@ use codex_core::config::ConfigBuilder; use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_path_by_id_str; use codex_core::find_thread_path_by_name_str; -use codex_core::protocol::SessionSource; use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; +use codex_protocol::protocol::SessionSource; use codex_state::StateRuntime; use codex_state::ThreadMetadataBuilder; use pretty_assertions::assert_eq; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 6f4fbe553b..247cfdaedc 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -1,12 +1,12 @@ use anyhow::Result; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ModelRerouteReason; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ModelRerouteReason; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_function_call; diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 7d2de4eb9a..1caab45b12 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -11,10 +11,10 @@ use codex_core::config::Config; use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses::ResponsesRequest; diff --git a/codex-rs/core/tests/suite/seatbelt.rs b/codex-rs/core/tests/suite/seatbelt.rs index 89674494a9..cfb91caeed 100644 --- a/codex-rs/core/tests/suite/seatbelt.rs +++ b/codex-rs/core/tests/suite/seatbelt.rs @@ -7,10 +7,10 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -use codex_core::protocol::SandboxPolicy; use codex_core::seatbelt::spawn_command_under_seatbelt; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use codex_core::spawn::StdioPolicy; +use codex_protocol::protocol::SandboxPolicy; use tempfile::TempDir; struct TestScenario { diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 6969b6533d..3e5942da6a 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -2,7 +2,7 @@ #![allow(clippy::expect_used)] use anyhow::Result; -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 6a0983a6ed..2a8017b393 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -1,12 +1,12 @@ use anyhow::Result; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 54cdba4d21..f681ca1b64 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -2,9 +2,9 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use anyhow::Result; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -84,7 +84,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { .await?; core_test_support::wait_for_event(test.codex.as_ref(), |event| { - matches!(event, codex_core::protocol::EventMsg::TurnComplete(_)) + matches!(event, codex_protocol::protocol::EventMsg::TurnComplete(_)) }) .await; @@ -124,7 +124,9 @@ async fn skill_load_errors_surface_in_session_configured() -> Result<()> { .await?; let response = core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event { - codex_core::protocol::EventMsg::ListSkillsResponse(response) => Some(response.clone()), + codex_protocol::protocol::EventMsg::ListSkillsResponse(response) => { + Some(response.clone()) + } _ => None, }) .await; @@ -192,7 +194,9 @@ async fn list_skills_includes_system_cache_entries() -> Result<()> { .await?; let response = core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event { - codex_core::protocol::EventMsg::ListSkillsResponse(response) => Some(response.clone()), + codex_protocol::protocol::EventMsg::ListSkillsResponse(response) => { + Some(response.clone()) + } _ => None, }) .await; diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index 3562ec2500..a8d1b37950 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -1,7 +1,7 @@ use codex_core::ModelProviderInfo; use codex_core::WireApi; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 6528e6277d..ac6d59b21b 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -3,8 +3,8 @@ use codex_core::ModelProviderInfo; use codex_core::WireApi; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::load_sse_fixture; use core_test_support::responses::ev_completed; diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 6114760253..507c8eb068 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -4,12 +4,12 @@ use std::fs; use assert_matches::assert_matches; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::plan_tool::StepStatus; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::responses; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 8db03c3d10..357dcff0fd 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -5,11 +5,11 @@ use std::fs; use std::time::Duration; use std::time::Instant; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 5e62cd698f..9f1803c75e 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -8,9 +8,9 @@ use std::time::Instant; use anyhow::Context; use anyhow::Result; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; use codex_core::sandboxing::SandboxPermissions; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 9475a9c510..8ede6c621f 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -5,11 +5,11 @@ use anyhow::Context; use anyhow::Result; use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::responses; diff --git a/codex-rs/core/tests/suite/undo.rs b/codex-rs/core/tests/suite/undo.rs index 61bc3b4fdf..e129a815bf 100644 --- a/codex-rs/core/tests/suite/undo.rs +++ b/codex-rs/core/tests/suite/undo.rs @@ -10,9 +10,9 @@ use anyhow::Result; use anyhow::bail; use codex_core::CodexThread; use codex_core::features::Feature; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::UndoCompletedEvent; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::UndoCompletedEvent; use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 34c5bd9617..705e415620 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -6,12 +6,12 @@ use std::sync::OnceLock; use anyhow::Context; use anyhow::Result; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecCommandSource; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandSource; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::process::process_is_alive; diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs index e5fdf61fdb..a215ecbc06 100644 --- a/codex-rs/core/tests/suite/unstable_features_warning.rs +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -4,9 +4,9 @@ use codex_config::CONFIG_TOML_FILE; use codex_core::CodexAuth; use codex_core::NewThread; use codex_core::features::Feature; -use codex_core::protocol::EventMsg; -use codex_core::protocol::InitialHistory; -use codex_core::protocol::WarningEvent; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::WarningEvent; use codex_utils_absolute_path::AbsolutePathBuf; use core::time::Duration; use core_test_support::load_default_config_for_test; diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 69f30bb5ad..5ef939776b 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -2,8 +2,8 @@ use std::os::unix::fs::PermissionsExt; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::fs_wait; use core_test_support::responses; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index a021509eea..c38b86e443 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -1,14 +1,14 @@ use anyhow::Context; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::ExecCommandSource; -use codex_core::protocol::ExecOutputStream; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol::TurnAbortReason; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::ExecCommandSource; +use codex_protocol::protocol::ExecOutputStream; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnAbortReason; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::responses; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index bad9efa2d0..96315e5c75 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -4,10 +4,6 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_core::CodexAuth; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; @@ -17,6 +13,10 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; diff --git a/codex-rs/core/tests/suite/web_search.rs b/codex-rs/core/tests/suite/web_search.rs index 8909e01cf4..e7082a832c 100644 --- a/codex-rs/core/tests/suite/web_search.rs +++ b/codex-rs/core/tests/suite/web_search.rs @@ -1,8 +1,8 @@ #![allow(clippy::unwrap_used)] use codex_core::features::Feature; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::protocol::SandboxPolicy; use core_test_support::responses; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index ceb7fc6057..b5c27ae923 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -1,10 +1,10 @@ use anyhow::Result; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_completed; diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 9346db63c1..4d9d79270d 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -25,6 +25,8 @@ async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-core = { workspace = true } codex-execpolicy = { workspace = true } +codex-protocol = { workspace = true } +codex-shell-command = { workspace = true } libc = { workspace = true } path-absolutize = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ @@ -57,6 +59,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } [dev-dependencies] codex-utils-cargo-bin = { workspace = true } +codex-protocol = { workspace = true } exec_server_test_support = { workspace = true } maplit = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/posix.rs index 7f9ce569c6..907ec14b68 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/posix.rs @@ -62,11 +62,11 @@ use std::sync::Arc; use anyhow::Context as _; use clap::Parser; use codex_core::config::find_codex_home; -use codex_core::is_dangerous_command::command_might_be_dangerous; use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Decision; use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; +use codex_shell_command::is_dangerous_command::command_might_be_dangerous; use rmcp::ErrorData as McpError; use tokio::sync::RwLock; use tracing_subscriber::EnvFilter; diff --git a/codex-rs/exec-server/src/posix/escalate_server.rs b/codex-rs/exec-server/src/posix/escalate_server.rs index f66e92dc8b..de4bdfc2cd 100644 --- a/codex-rs/exec-server/src/posix/escalate_server.rs +++ b/codex-rs/exec-server/src/posix/escalate_server.rs @@ -10,8 +10,8 @@ use path_absolutize::Absolutize as _; use codex_core::SandboxState; use codex_core::exec::process_exec_tool_call; -use codex_core::protocol_config_types::WindowsSandboxLevel; use codex_core::sandboxing::SandboxPermissions; +use codex_protocol::config_types::WindowsSandboxLevel; use tokio::process::Command; use tokio_util::sync::CancellationToken; diff --git a/codex-rs/exec-server/src/posix/mcp.rs b/codex-rs/exec-server/src/posix/mcp.rs index 7e8d0ccdff..5319a1f916 100644 --- a/codex-rs/exec-server/src/posix/mcp.rs +++ b/codex-rs/exec-server/src/posix/mcp.rs @@ -7,8 +7,8 @@ use anyhow::Result; use codex_core::MCP_SANDBOX_STATE_CAPABILITY; use codex_core::MCP_SANDBOX_STATE_METHOD; use codex_core::SandboxState; -use codex_core::protocol::SandboxPolicy; use codex_execpolicy::Policy; +use codex_protocol::protocol::SandboxPolicy; use rmcp::ErrorData as McpError; use rmcp::RoleServer; use rmcp::ServerHandler; diff --git a/codex-rs/exec-server/tests/common/Cargo.toml b/codex-rs/exec-server/tests/common/Cargo.toml index 6444b61f97..ca1a713c27 100644 --- a/codex-rs/exec-server/tests/common/Cargo.toml +++ b/codex-rs/exec-server/tests/common/Cargo.toml @@ -10,6 +10,7 @@ path = "lib.rs" [dependencies] anyhow = { workspace = true } codex-core = { workspace = true } +codex-protocol = { workspace = true } codex-utils-cargo-bin = { workspace = true } rmcp = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/exec-server/tests/common/lib.rs b/codex-rs/exec-server/tests/common/lib.rs index 57fce3a150..bd602d09c8 100644 --- a/codex-rs/exec-server/tests/common/lib.rs +++ b/codex-rs/exec-server/tests/common/lib.rs @@ -1,6 +1,6 @@ use codex_core::MCP_SANDBOX_STATE_METHOD; use codex_core::SandboxState; -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; use codex_utils_cargo_bin::find_resource; use rmcp::ClientHandler; use rmcp::ErrorData as McpError; diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index 2a0b1eb833..fc9d123e0d 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -1,8 +1,8 @@ use std::path::Path; use codex_core::config::Config; -use codex_core::protocol::Event; -use codex_core::protocol::SessionConfiguredEvent; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::SessionConfiguredEvent; pub(crate) enum CodexStatus { Running, diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 1e2a1b9e6d..13599294d5 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -1,39 +1,39 @@ use codex_core::config::Config; -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::AgentReasoningRawContentEvent; -use codex_core::protocol::AgentStatus; -use codex_core::protocol::BackgroundEventEvent; -use codex_core::protocol::CollabAgentInteractionBeginEvent; -use codex_core::protocol::CollabAgentInteractionEndEvent; -use codex_core::protocol::CollabAgentSpawnBeginEvent; -use codex_core::protocol::CollabAgentSpawnEndEvent; -use codex_core::protocol::CollabCloseBeginEvent; -use codex_core::protocol::CollabCloseEndEvent; -use codex_core::protocol::CollabWaitingBeginEvent; -use codex_core::protocol::CollabWaitingEndEvent; -use codex_core::protocol::DeprecationNoticeEvent; -use codex_core::protocol::ErrorEvent; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::FileChange; -use codex_core::protocol::ItemCompletedEvent; -use codex_core::protocol::McpInvocation; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::PatchApplyBeginEvent; -use codex_core::protocol::PatchApplyEndEvent; -use codex_core::protocol::SessionConfiguredEvent; -use codex_core::protocol::StreamErrorEvent; -use codex_core::protocol::TurnAbortReason; -use codex_core::protocol::TurnCompleteEvent; -use codex_core::protocol::TurnDiffEvent; -use codex_core::protocol::WarningEvent; -use codex_core::protocol::WebSearchEndEvent; use codex_core::web_search::web_search_detail; use codex_protocol::items::TurnItem; use codex_protocol::num_format::format_with_separators; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::AgentReasoningRawContentEvent; +use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::BackgroundEventEvent; +use codex_protocol::protocol::CollabAgentInteractionBeginEvent; +use codex_protocol::protocol::CollabAgentInteractionEndEvent; +use codex_protocol::protocol::CollabAgentSpawnBeginEvent; +use codex_protocol::protocol::CollabAgentSpawnEndEvent; +use codex_protocol::protocol::CollabCloseBeginEvent; +use codex_protocol::protocol::CollabCloseEndEvent; +use codex_protocol::protocol::CollabWaitingBeginEvent; +use codex_protocol::protocol::CollabWaitingEndEvent; +use codex_protocol::protocol::DeprecationNoticeEvent; +use codex_protocol::protocol::ErrorEvent; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::McpToolCallBeginEvent; +use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::PatchApplyBeginEvent; +use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::protocol::SessionConfiguredEvent; +use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::protocol::TurnDiffEvent; +use codex_protocol::protocol::WarningEvent; +use codex_protocol::protocol::WebSearchEndEvent; use codex_utils_elapsed::format_duration; use codex_utils_elapsed::format_elapsed; use owo_colors::OwoColorize; @@ -73,7 +73,7 @@ pub(crate) struct EventProcessorWithHumanOutput { show_agent_reasoning: bool, show_raw_agent_reasoning: bool, last_message_path: Option, - last_total_token_usage: Option, + last_total_token_usage: Option, final_message: Option, last_proposed_plan: Option, } @@ -201,10 +201,12 @@ impl EventProcessor for EventProcessorWithHumanOutput { } EventMsg::McpStartupUpdate(update) => { let status_text = match update.status { - codex_core::protocol::McpStartupStatus::Starting => "starting".to_string(), - codex_core::protocol::McpStartupStatus::Ready => "ready".to_string(), - codex_core::protocol::McpStartupStatus::Cancelled => "cancelled".to_string(), - codex_core::protocol::McpStartupStatus::Failed { ref error } => { + codex_protocol::protocol::McpStartupStatus::Starting => "starting".to_string(), + codex_protocol::protocol::McpStartupStatus::Ready => "ready".to_string(), + codex_protocol::protocol::McpStartupStatus::Cancelled => { + "cancelled".to_string() + } + codex_protocol::protocol::McpStartupStatus::Failed { ref error } => { format!("failed: {error}") } }; diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 6cefcbd940..8bec82b15d 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -39,19 +39,19 @@ use crate::exec_events::TurnStartedEvent; use crate::exec_events::Usage; use crate::exec_events::WebSearchItem; use codex_core::config::Config; -use codex_core::protocol; -use codex_core::protocol::AgentStatus as CoreAgentStatus; -use codex_core::protocol::CollabAgentInteractionBeginEvent; -use codex_core::protocol::CollabAgentInteractionEndEvent; -use codex_core::protocol::CollabAgentSpawnBeginEvent; -use codex_core::protocol::CollabAgentSpawnEndEvent; -use codex_core::protocol::CollabCloseBeginEvent; -use codex_core::protocol::CollabCloseEndEvent; -use codex_core::protocol::CollabWaitingBeginEvent; -use codex_core::protocol::CollabWaitingEndEvent; use codex_protocol::models::WebSearchAction; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol; +use codex_protocol::protocol::AgentStatus as CoreAgentStatus; +use codex_protocol::protocol::CollabAgentInteractionBeginEvent; +use codex_protocol::protocol::CollabAgentInteractionEndEvent; +use codex_protocol::protocol::CollabAgentSpawnBeginEvent; +use codex_protocol::protocol::CollabAgentSpawnEndEvent; +use codex_protocol::protocol::CollabCloseBeginEvent; +use codex_protocol::protocol::CollabCloseEndEvent; +use codex_protocol::protocol::CollabWaitingBeginEvent; +use codex_protocol::protocol::CollabWaitingEndEvent; use serde_json::Value as JsonValue; use tracing::error; use tracing::warn; @@ -65,7 +65,7 @@ pub struct EventProcessorWithJsonOutput { running_patch_applies: HashMap, // Tracks the todo list for the current turn (at most one per turn). running_todo_list: Option, - last_total_token_usage: Option, + last_total_token_usage: Option, running_mcp_tool_calls: HashMap, running_collab_tool_calls: HashMap, running_web_search_calls: HashMap, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 70ddb70f13..89180d89e6 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -32,15 +32,15 @@ use codex_core::config_loader::format_config_error_with_source; use codex_core::format_exec_policy_error_with_source; use codex_core::git_info::get_git_repo_root; use codex_core::models_manager::manager::RefreshStrategy; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_core::protocol::ReviewRequest; -use codex_core::protocol::ReviewTarget; -use codex_core::protocol::SessionSource; use codex_protocol::approvals::ElicitationAction; use codex_protocol::config_types::SandboxMode; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewRequest; +use codex_protocol::protocol::ReviewTarget; +use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_oss::ensure_oss_provider_ready; @@ -570,7 +570,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any } if let EventMsg::McpStartupUpdate(update) = &event.msg && required_mcp_servers.contains(&update.server) - && let codex_core::protocol::McpStartupStatus::Failed { error } = &update.status + && let codex_protocol::protocol::McpStartupStatus::Failed { error } = &update.status { error_seen = true; eprintln!( diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index c04fcdb532..6595e82eab 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -1,29 +1,3 @@ -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::AgentReasoningEvent; -use codex_core::protocol::AgentStatus; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::CollabAgentSpawnBeginEvent; -use codex_core::protocol::CollabAgentSpawnEndEvent; -use codex_core::protocol::CollabWaitingEndEvent; -use codex_core::protocol::ErrorEvent; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::ExecCommandSource; -use codex_core::protocol::ExecCommandStatus as CoreExecCommandStatus; -use codex_core::protocol::FileChange; -use codex_core::protocol::McpInvocation; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::PatchApplyBeginEvent; -use codex_core::protocol::PatchApplyEndEvent; -use codex_core::protocol::PatchApplyStatus as CorePatchApplyStatus; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol::SessionConfiguredEvent; -use codex_core::protocol::WarningEvent; -use codex_core::protocol::WebSearchBeginEvent; -use codex_core::protocol::WebSearchEndEvent; use codex_exec::event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use codex_exec::exec_events::AgentMessageItem; use codex_exec::exec_events::CollabAgentState; @@ -63,9 +37,35 @@ use codex_protocol::models::WebSearchAction; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::AgentReasoningEvent; +use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::CollabAgentSpawnBeginEvent; +use codex_protocol::protocol::CollabAgentSpawnEndEvent; +use codex_protocol::protocol::CollabWaitingEndEvent; +use codex_protocol::protocol::ErrorEvent; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandOutputDeltaEvent; +use codex_protocol::protocol::ExecCommandSource; +use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; use codex_protocol::protocol::ExecOutputStream; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::McpToolCallBeginEvent; +use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::PatchApplyBeginEvent; +use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionConfiguredEvent; +use codex_protocol::protocol::WarningEvent; +use codex_protocol::protocol::WebSearchBeginEvent; +use codex_protocol::protocol::WebSearchEndEvent; use pretty_assertions::assert_eq; use rmcp::model::Content; use serde_json::json; @@ -118,7 +118,7 @@ fn task_started_produces_turn_started_event() { let mut ep = EventProcessorWithJsonOutput::new(None); let out = ep.collect_thread_events(&event( "t1", - EventMsg::TurnStarted(codex_core::protocol::TurnStartedEvent { + EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), model_context_window: Some(32_000), collaboration_mode_kind: ModeKind::Default, @@ -312,7 +312,7 @@ fn plan_update_emits_todo_list_started_updated_and_completed() { // Task completes => item.completed (same id, latest state) let complete = event( "p3", - EventMsg::TurnComplete(codex_core::protocol::TurnCompleteEvent { + EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, }), @@ -683,7 +683,7 @@ fn plan_update_after_complete_starts_new_todo_list_with_new_id() { let _ = ep.collect_thread_events(&start); let complete = event( "t2", - EventMsg::TurnComplete(codex_core::protocol::TurnCompleteEvent { + EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, }), @@ -763,7 +763,7 @@ fn error_event_produces_error() { let mut ep = EventProcessorWithJsonOutput::new(None); let out = ep.collect_thread_events(&event( "e1", - EventMsg::Error(codex_core::protocol::ErrorEvent { + EventMsg::Error(codex_protocol::protocol::ErrorEvent { message: "boom".to_string(), codex_error_info: Some(CodexErrorInfo::Other), }), @@ -803,7 +803,7 @@ fn stream_error_event_produces_error() { let mut ep = EventProcessorWithJsonOutput::new(None); let out = ep.collect_thread_events(&event( "e1", - EventMsg::StreamError(codex_core::protocol::StreamErrorEvent { + EventMsg::StreamError(codex_protocol::protocol::StreamErrorEvent { message: "retrying".to_string(), codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, @@ -837,7 +837,7 @@ fn error_followed_by_task_complete_produces_turn_failed() { let complete_event = event( "e2", - EventMsg::TurnComplete(codex_core::protocol::TurnCompleteEvent { + EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, }), @@ -1267,21 +1267,21 @@ fn task_complete_produces_turn_completed_with_usage() { let mut ep = EventProcessorWithJsonOutput::new(None); // First, feed a TokenCount event with known totals. - let usage = codex_core::protocol::TokenUsage { + let usage = codex_protocol::protocol::TokenUsage { input_tokens: 1200, cached_input_tokens: 200, output_tokens: 345, reasoning_output_tokens: 0, total_tokens: 0, }; - let info = codex_core::protocol::TokenUsageInfo { + let info = codex_protocol::protocol::TokenUsageInfo { total_token_usage: usage.clone(), last_token_usage: usage, model_context_window: None, }; let token_count_event = event( "e1", - EventMsg::TokenCount(codex_core::protocol::TokenCountEvent { + EventMsg::TokenCount(codex_protocol::protocol::TokenCountEvent { info: Some(info), rate_limits: None, }), @@ -1291,7 +1291,7 @@ fn task_complete_produces_turn_completed_with_usage() { // Then TurnComplete should produce turn.completed with the captured usage. let complete_event = event( "e2", - EventMsg::TurnComplete(codex_core::protocol::TurnCompleteEvent { + EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), }), diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index ce2e79eac2..e6dd37927b 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -1,6 +1,6 @@ #![cfg(unix)] -use codex_core::protocol::SandboxPolicy; use codex_core::spawn::StdioPolicy; +use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::future::Future; diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index ff16a569fe..63af5c81fa 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -18,6 +18,7 @@ workspace = true [target.'cfg(target_os = "linux")'.dependencies] clap = { workspace = true, features = ["derive"] } codex-core = { workspace = true } +codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } landlock = { workspace = true } libc = { workspace = true } diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 71704e7299..854774b781 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -15,8 +15,8 @@ use std::path::PathBuf; use codex_core::error::CodexErr; use codex_core::error::Result; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol::WritableRoot; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::WritableRoot; /// Options that control how bubblewrap is invoked. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -322,7 +322,7 @@ fn find_first_non_existent_component(target_path: &Path) -> Option { #[cfg(test)] mod tests { use super::*; - use codex_core::protocol::SandboxPolicy; + use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; #[test] diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 8d568dba0a..621bfd5b60 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -8,7 +8,7 @@ use std::path::Path; use codex_core::error::CodexErr; use codex_core::error::Result; use codex_core::error::SandboxErr; -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use landlock::ABI; @@ -204,7 +204,7 @@ fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), #[cfg(test)] mod tests { use super::should_install_network_seccomp; - use codex_core::protocol::SandboxPolicy; + use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; #[test] diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index c2a6a4d312..84e7cb34fc 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -25,7 +25,7 @@ pub struct LandlockCommand { pub sandbox_policy_cwd: PathBuf, #[arg(long = "sandbox-policy")] - pub sandbox_policy: codex_core::protocol::SandboxPolicy, + pub sandbox_policy: codex_protocol::protocol::SandboxPolicy, /// Opt-in: use the bubblewrap-based Linux sandbox pipeline. /// @@ -142,7 +142,7 @@ pub fn run_main() -> ! { fn run_bwrap_with_proc_fallback( sandbox_policy_cwd: &Path, - sandbox_policy: &codex_core::protocol::SandboxPolicy, + sandbox_policy: &codex_protocol::protocol::SandboxPolicy, inner: Vec, mount_proc: bool, allow_network_for_proxy: bool, @@ -164,7 +164,7 @@ fn run_bwrap_with_proc_fallback( } fn bwrap_network_mode( - sandbox_policy: &codex_core::protocol::SandboxPolicy, + sandbox_policy: &codex_protocol::protocol::SandboxPolicy, allow_network_for_proxy: bool, ) -> BwrapNetworkMode { if allow_network_for_proxy { @@ -178,7 +178,7 @@ fn bwrap_network_mode( fn build_bwrap_argv( inner: Vec, - sandbox_policy: &codex_core::protocol::SandboxPolicy, + sandbox_policy: &codex_protocol::protocol::SandboxPolicy, sandbox_policy_cwd: &Path, options: BwrapOptions, ) -> Vec { @@ -201,7 +201,7 @@ fn build_bwrap_argv( fn preflight_proc_mount_support( sandbox_policy_cwd: &Path, - sandbox_policy: &codex_core::protocol::SandboxPolicy, + sandbox_policy: &codex_protocol::protocol::SandboxPolicy, ) -> bool { let preflight_command = vec![resolve_true_command()]; let preflight_argv = build_bwrap_argv( @@ -315,7 +315,7 @@ fn is_proc_mount_failure(stderr: &str) -> bool { /// Build the inner command that applies seccomp after bubblewrap. fn build_inner_seccomp_command( sandbox_policy_cwd: &Path, - sandbox_policy: &codex_core::protocol::SandboxPolicy, + sandbox_policy: &codex_protocol::protocol::SandboxPolicy, use_bwrap_sandbox: bool, allow_network_for_proxy: bool, command: Vec, @@ -374,7 +374,7 @@ fn exec_or_panic(command: Vec) -> ! { #[cfg(test)] mod tests { use super::*; - use codex_core::protocol::SandboxPolicy; + use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; #[test] diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 9a1b82413e..362fcaf35f 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -7,9 +7,9 @@ use codex_core::error::SandboxErr; use codex_core::exec::ExecParams; use codex_core::exec::process_exec_tool_call; use codex_core::exec_env::create_env; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::WindowsSandboxLevel; use codex_core::sandboxing::SandboxPermissions; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::HashMap; diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index e3f0db82eb..2ecce383ce 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -20,6 +20,7 @@ anyhow = { workspace = true } codex-arg0 = { workspace = true } codex-core = { workspace = true } codex-protocol = { workspace = true } +codex-shell-command = { workspace = true } codex-utils-cli = { workspace = true } codex-utils-json-to-toml = { workspace = true } rmcp = { workspace = true } diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index cf34dc280e..3125221d1a 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -2,9 +2,9 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_core::protocol::AskForApproval; use codex_protocol::ThreadId; use codex_protocol::config_types::SandboxMode; +use codex_protocol::protocol::AskForApproval; use codex_utils_json_to_toml::json_to_toml; use rmcp::model::JsonObject; use rmcp::model::Tool; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 762052ccf4..b720a1b258 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -13,15 +13,15 @@ use codex_core::CodexThread; use codex_core::NewThread; use codex_core::ThreadManager; use codex_core::config::Config as CodexConfig; -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::Op; -use codex_core::protocol::Submission; -use codex_core::protocol::TurnCompleteEvent; use codex_protocol::ThreadId; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::Submission; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::user_input::UserInput; use rmcp::model::CallToolResult; use rmcp::model::Content; diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index 54994f9fdb..3b5ef87cdb 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -2,10 +2,10 @@ use std::path::PathBuf; use std::sync::Arc; use codex_core::CodexThread; -use codex_core::protocol::Op; -use codex_core::protocol::ReviewDecision; use codex_protocol::ThreadId; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; use rmcp::model::ErrorData; use rmcp::model::RequestId; use serde::Deserialize; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 3b18f2fd43..95d48aa822 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -6,9 +6,9 @@ use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::default_client::USER_AGENT_SUFFIX; use codex_core::default_client::get_codex_user_agent; -use codex_core::protocol::Submission; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::Submission; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; use rmcp::model::ClientNotification; @@ -572,7 +572,7 @@ impl MessageProcessor { if let Err(e) = codex_arc .submit_with_id(Submission { id: request_id_string, - op: codex_core::protocol::Op::Interrupt, + op: codex_protocol::protocol::Op::Interrupt, }) .await { diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 3f83e1990e..7dcfa0c233 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; -use codex_core::protocol::Event; use codex_protocol::ThreadId; +use codex_protocol::protocol::Event; use rmcp::model::CustomNotification; use rmcp::model::CustomRequest; use rmcp::model::ErrorData; @@ -200,7 +200,7 @@ pub(crate) struct OutgoingNotificationParams { pub event: serde_json::Value, } -// Additional mcp-specific data to be added to a [`codex_core::protocol::Event`] as notification.params._meta +// Additional mcp-specific data to be added to a [`codex_protocol::protocol::Event`] as notification.params._meta // MCP Spec: https://modelcontextprotocol.io/specification/2025-06-18/basic#meta // Typescript Schema: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/0695a497eb50a804fc0e88c18a93a21a675d6b3e/schema/2025-06-18/schema.ts #[derive(Debug, Clone, PartialEq, Serialize)] @@ -231,12 +231,12 @@ mod tests { use std::path::PathBuf; use anyhow::Result; - use codex_core::protocol::AskForApproval; - use codex_core::protocol::EventMsg; - use codex_core::protocol::SandboxPolicy; - use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::ThreadId; use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::protocol::AskForApproval; + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::protocol::SessionConfiguredEvent; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::NamedTempFile; diff --git a/codex-rs/mcp-server/src/patch_approval.rs b/codex-rs/mcp-server/src/patch_approval.rs index 60eba66433..e392694d3a 100644 --- a/codex-rs/mcp-server/src/patch_approval.rs +++ b/codex-rs/mcp-server/src/patch_approval.rs @@ -3,10 +3,10 @@ use std::path::PathBuf; use std::sync::Arc; use codex_core::CodexThread; -use codex_core::protocol::FileChange; -use codex_core::protocol::Op; -use codex_core::protocol::ReviewDecision; use codex_protocol::ThreadId; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; use rmcp::model::ErrorData; use rmcp::model::RequestId; use serde::Deserialize; diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index de8a9306d4..041fcc0217 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -3,15 +3,15 @@ use std::env; use std::path::Path; use std::path::PathBuf; -use codex_core::parse_command; -use codex_core::protocol::FileChange; -use codex_core::protocol::ReviewDecision; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_mcp_server::CodexToolCallParam; use codex_mcp_server::ExecApprovalElicitRequestParams; use codex_mcp_server::ExecApprovalResponse; use codex_mcp_server::PatchApprovalElicitRequestParams; use codex_mcp_server::PatchApprovalResponse; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::ReviewDecision; +use codex_shell_command::parse_command; use pretty_assertions::assert_eq; use rmcp::model::JsonRpcResponse; use rmcp::model::JsonRpcVersion2_0; diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index f791a3fc98..683b0828dc 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -38,6 +38,7 @@ codex-file-search = { workspace = true } codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } +codex-shell-command = { workspace = true } codex-state = { workspace = true } codex-utils-approval-presets = { workspace = true } codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/tui/src/additional_dirs.rs b/codex-rs/tui/src/additional_dirs.rs index 98724aa161..f7d2ef5508 100644 --- a/codex-rs/tui/src/additional_dirs.rs +++ b/codex-rs/tui/src/additional_dirs.rs @@ -1,4 +1,4 @@ -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; use std::path::PathBuf; /// Returns a warning describing why `--add-dir` entries will be ignored for the @@ -34,8 +34,8 @@ fn format_warning(additional_dirs: &[PathBuf]) -> String { #[cfg(test)] mod tests { use super::add_dir_warning_message; - use codex_core::protocol::NetworkAccess; - use codex_core::protocol::SandboxPolicy; + use codex_protocol::protocol::NetworkAccess; + use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::path::PathBuf; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0090ef5e9b..ebbf757523 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -45,16 +45,6 @@ use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::FinalOutput; -use codex_core::protocol::ListSkillsResponseEvent; -use codex_core::protocol::Op; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol::SessionSource; -use codex_core::protocol::SkillErrorInfo; -use codex_core::protocol::TokenUsage; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_otel::OtelManager; @@ -67,7 +57,17 @@ use codex_protocol::items::TurnItem; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::FinalOutput; +use codex_protocol::protocol::ListSkillsResponseEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SkillErrorInfo; +use codex_protocol::protocol::TokenUsage; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -1248,8 +1248,8 @@ impl App { != WindowsSandboxLevel::Disabled && matches!( app.config.permissions.sandbox_policy.get(), - codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_core::protocol::SandboxPolicy::ReadOnly { .. } + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } ) && !app .config @@ -2200,8 +2200,8 @@ impl App { #[cfg(target_os = "windows")] let policy_is_workspace_write_or_ro = matches!( &policy, - codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_core::protocol::SandboxPolicy::ReadOnly { .. } + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } ); let policy_for_chat = policy.clone(); @@ -2767,7 +2767,7 @@ impl App { (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) } - pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { + pub(crate) fn token_usage(&self) -> codex_protocol::protocol::TokenUsage { self.chat_widget.token_usage() } @@ -2939,7 +2939,7 @@ impl App { cwd: PathBuf, env_map: std::collections::HashMap, logs_base_dir: PathBuf, - sandbox_policy: codex_core::protocol::SandboxPolicy, + sandbox_policy: codex_protocol::protocol::SandboxPolicy, tx: AppEventSender, ) { tokio::task::spawn_blocking(move || { @@ -3000,16 +3000,16 @@ mod tests { use codex_core::CodexAuth; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; - use codex_core::protocol::AskForApproval; - use codex_core::protocol::Event; - use codex_core::protocol::EventMsg; - use codex_core::protocol::SandboxPolicy; - use codex_core::protocol::SessionConfiguredEvent; - use codex_core::protocol::SessionSource; - use codex_core::protocol::ThreadRolledBackEvent; - use codex_core::protocol::UserMessageEvent; use codex_otel::OtelManager; use codex_protocol::ThreadId; + use codex_protocol::protocol::AskForApproval; + use codex_protocol::protocol::Event; + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::protocol::SessionConfiguredEvent; + use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::ThreadRolledBackEvent; + use codex_protocol::protocol::UserMessageEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use crossterm::event::KeyModifiers; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b9b5be2b5f..1bb2c29569 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -34,11 +34,11 @@ use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; -use codex_core::protocol::CodexErrorInfo; -use codex_core::protocol::ErrorEvent; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; use codex_protocol::ThreadId; +use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::ErrorEvent; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_protocol::user_input::TextElement; use color_eyre::eyre::Result; use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 17ce9f14b4..159cda88f4 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -11,11 +11,11 @@ use std::path::PathBuf; use codex_chatgpt::connectors::AppInfo; -use codex_core::protocol::Event; -use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::RateLimitSnapshot; use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; @@ -23,11 +23,11 @@ use crate::bottom_pane::StatusLineItem; use crate::history_cell::HistoryCell; use codex_core::features::Feature; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::Personality; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] @@ -73,7 +73,7 @@ pub(crate) enum AppEvent { /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids /// bubbling channels through layers of widgets. - CodexOp(codex_core::protocol::Op), + CodexOp(codex_protocol::protocol::Op), /// Kick off an asynchronous file search for the given query (text after /// the `@`). Previous searches may be cancelled by the app layer so there diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 169c8bdcc6..bac964b919 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -17,13 +17,13 @@ use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use codex_core::features::Features; -use codex_core::protocol::ElicitationAction; -use codex_core::protocol::ExecPolicyAmendment; -use codex_core::protocol::FileChange; -use codex_core::protocol::NetworkApprovalContext; -use codex_core::protocol::Op; -use codex_core::protocol::ReviewDecision; use codex_protocol::mcp::RequestId; +use codex_protocol::protocol::ElicitationAction; +use codex_protocol::protocol::ExecPolicyAmendment; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::NetworkApprovalContext; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -576,7 +576,7 @@ fn elicitation_options() -> Vec { mod tests { use super::*; use crate::app_event::AppEvent; - use codex_core::protocol::NetworkApprovalProtocol; + use codex_protocol::protocol::NetworkApprovalProtocol; use pretty_assertions::assert_eq; use tokio::sync::mpsc::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 91dfa00a52..8498cd9b42 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3252,7 +3252,7 @@ impl ChatComposer { insert_text: format!("${skill_name}"), search_terms, path: Some(skill.path.to_string_lossy().into_owned()), - category_tag: (skill.scope == codex_core::protocol::SkillScope::Repo) + category_tag: (skill.scope == codex_protocol::protocol::SkillScope::Repo) .then(|| "[Repo]".to_string()), }); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 604641d208..f6df497187 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -5,7 +5,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::MentionBinding; use crate::mention_codec::decode_history_mentions; -use codex_core::protocol::Op; +use codex_protocol::protocol::Op; use codex_protocol::user_input::TextElement; /// A composer history entry that can rehydrate draft state. @@ -292,7 +292,7 @@ impl ChatComposerHistory { mod tests { use super::*; use crate::app_event::AppEvent; - use codex_core::protocol::Op; + use codex_protocol::protocol::Op; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index eeca6aef18..03d3779c57 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -19,7 +19,7 @@ use crate::app_event::FeedbackCategory; use crate::app_event_sender::AppEventSender; use crate::history_cell; use crate::render::renderable::Renderable; -use codex_core::protocol::SessionSource; +use codex_protocol::protocol::SessionSource; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 46917dd9fa..234bb8b728 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -979,7 +979,7 @@ impl Renderable for BottomPane { mod tests { use super::*; use crate::app_event::AppEvent; - use codex_core::protocol::Op; + use codex_protocol::protocol::Op; use codex_protocol::protocol::SkillScope; use crossterm::event::KeyModifiers; use insta::assert_snapshot; 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 c2a4291cf1..79b1229800 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 @@ -30,7 +30,7 @@ use crate::bottom_pane::selection_popup_common::measure_rows_height; use crate::history_cell; use crate::render::renderable::Renderable; -use codex_core::protocol::Op; +use codex_protocol::protocol::Op; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputResponse; 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 7b25b9841e..1e0230c904 100644 --- a/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs +++ b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs @@ -22,7 +22,7 @@ use crate::render::renderable::Renderable; use crate::skills_helpers::match_skill; use crate::skills_helpers::truncate_skill_name; use crate::style::user_message_style; -use codex_core::protocol::Op; +use codex_protocol::protocol::Op; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1cd3b3e14f..238a60730c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -61,54 +61,6 @@ use codex_core::git_info::get_git_repo_root; use codex_core::git_info::local_git_branches; use codex_core::models_manager::manager::ModelsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; -use codex_core::protocol::AgentMessageDeltaEvent; -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::AgentReasoningDeltaEvent; -use codex_core::protocol::AgentReasoningEvent; -use codex_core::protocol::AgentReasoningRawContentDeltaEvent; -use codex_core::protocol::AgentReasoningRawContentEvent; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::BackgroundEventEvent; -use codex_core::protocol::CodexErrorInfo; -use codex_core::protocol::CreditsSnapshot; -use codex_core::protocol::DeprecationNoticeEvent; -use codex_core::protocol::ErrorEvent; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::ExecCommandOutputDeltaEvent; -use codex_core::protocol::ExecCommandSource; -use codex_core::protocol::ExitedReviewModeEvent; -use codex_core::protocol::ListCustomPromptsResponseEvent; -use codex_core::protocol::ListSkillsResponseEvent; -use codex_core::protocol::McpListToolsResponseEvent; -use codex_core::protocol::McpStartupCompleteEvent; -use codex_core::protocol::McpStartupStatus; -use codex_core::protocol::McpStartupUpdateEvent; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::Op; -use codex_core::protocol::PatchApplyBeginEvent; -use codex_core::protocol::RateLimitSnapshot; -use codex_core::protocol::ReviewRequest; -use codex_core::protocol::ReviewTarget; -use codex_core::protocol::SkillMetadata as ProtocolSkillMetadata; -use codex_core::protocol::StreamErrorEvent; -use codex_core::protocol::TerminalInteractionEvent; -use codex_core::protocol::TokenUsage; -use codex_core::protocol::TokenUsageInfo; -use codex_core::protocol::TurnAbortReason; -use codex_core::protocol::TurnCompleteEvent; -use codex_core::protocol::TurnDiffEvent; -use codex_core::protocol::UndoCompletedEvent; -use codex_core::protocol::UndoStartedEvent; -use codex_core::protocol::UserMessageEvent; -use codex_core::protocol::ViewImageToolCallEvent; -use codex_core::protocol::WarningEvent; -use codex_core::protocol::WebSearchBeginEvent; -use codex_core::protocol::WebSearchEndEvent; use codex_core::skills::model::SkillMetadata; use codex_core::terminal::TerminalName; use codex_core::terminal::terminal_info; @@ -130,6 +82,54 @@ 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::protocol::AgentMessageDeltaEvent; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::AgentReasoningDeltaEvent; +use codex_protocol::protocol::AgentReasoningEvent; +use codex_protocol::protocol::AgentReasoningRawContentDeltaEvent; +use codex_protocol::protocol::AgentReasoningRawContentEvent; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::BackgroundEventEvent; +use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::DeprecationNoticeEvent; +use codex_protocol::protocol::ErrorEvent; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::ExecCommandOutputDeltaEvent; +use codex_protocol::protocol::ExecCommandSource; +use codex_protocol::protocol::ExitedReviewModeEvent; +use codex_protocol::protocol::ListCustomPromptsResponseEvent; +use codex_protocol::protocol::ListSkillsResponseEvent; +use codex_protocol::protocol::McpListToolsResponseEvent; +use codex_protocol::protocol::McpStartupCompleteEvent; +use codex_protocol::protocol::McpStartupStatus; +use codex_protocol::protocol::McpStartupUpdateEvent; +use codex_protocol::protocol::McpToolCallBeginEvent; +use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::PatchApplyBeginEvent; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::ReviewRequest; +use codex_protocol::protocol::ReviewTarget; +use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; +use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::TerminalInteractionEvent; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::protocol::TurnDiffEvent; +use codex_protocol::protocol::UndoCompletedEvent; +use codex_protocol::protocol::UndoStartedEvent; +use codex_protocol::protocol::UserMessageEvent; +use codex_protocol::protocol::ViewImageToolCallEvent; +use codex_protocol::protocol::WarningEvent; +use codex_protocol::protocol::WebSearchBeginEvent; +use codex_protocol::protocol::WebSearchEndEvent; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; @@ -268,13 +268,13 @@ use chrono::Local; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ThreadManager; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; use codex_file_search::FileMatch; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_utils_approval_presets::ApprovalPreset; use codex_utils_approval_presets::builtin_approval_presets; use strum::IntoEnumIterator; @@ -650,7 +650,7 @@ pub(crate) struct ChatWidget { // Current working directory (if known) current_cwd: Option, // Runtime network proxy bind addresses from SessionConfigured. - session_network_proxy: Option, + session_network_proxy: Option, // Shared latch so we only warn once about invalid status-line item IDs. status_line_invalid_items_warned: Arc, // Cached git branch name for the status line (None if unknown). @@ -1060,7 +1060,7 @@ impl ChatWidget { } // --- Small event handlers --- - fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { + fn on_session_configured(&mut self, event: codex_protocol::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); @@ -1159,7 +1159,7 @@ impl ChatWidget { }); } - fn on_thread_name_updated(&mut self, event: codex_core::protocol::ThreadNameUpdatedEvent) { + fn on_thread_name_updated(&mut self, event: codex_protocol::protocol::ThreadNameUpdatedEvent) { if self.thread_id == Some(event.thread_id) { self.thread_name = event.thread_name; self.request_redraw(); @@ -1982,7 +1982,7 @@ impl ChatWidget { self.request_redraw(); } - fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) { + fn on_patch_apply_end(&mut self, event: codex_protocol::protocol::PatchApplyEndEvent) { let ev2 = event.clone(); self.defer_or_handle( |q| q.push_patch_end(event), @@ -2144,9 +2144,9 @@ impl ChatWidget { fn on_get_history_entry_response( &mut self, - event: codex_core::protocol::GetHistoryEntryResponseEvent, + event: codex_protocol::protocol::GetHistoryEntryResponseEvent, ) { - let codex_core::protocol::GetHistoryEntryResponseEvent { + let codex_protocol::protocol::GetHistoryEntryResponseEvent { offset, log_id, entry, @@ -2412,7 +2412,7 @@ impl ChatWidget { pub(crate) fn handle_patch_apply_end_now( &mut self, - event: codex_core::protocol::PatchApplyEndEvent, + event: codex_protocol::protocol::PatchApplyEndEvent, ) { // If the patch was successful, just let the "Edited" block stand. // Otherwise, add a failure block. @@ -2923,7 +2923,7 @@ impl ChatWidget { pub(crate) fn new_from_existing( common: ChatWidgetInit, conversation: std::sync::Arc, - session_configured: codex_core::protocol::SessionConfiguredEvent, + session_configured: codex_protocol::protocol::SessionConfiguredEvent, ) -> Self { let ChatWidgetInit { config, @@ -3505,11 +3505,11 @@ impl ChatWidget { } } SlashCommand::TestApproval => { - use codex_core::protocol::EventMsg; + use codex_protocol::protocol::EventMsg; use std::collections::HashMap; - use codex_core::protocol::ApplyPatchApprovalRequestEvent; - use codex_core::protocol::FileChange; + use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; + use codex_protocol::protocol::FileChange; self.app_event_tx.send(AppEvent::CodexEvent(Event { id: "1".to_string(), diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs index d905f884f8..63d519a7a5 100644 --- a/codex-rs/tui/src/chatwidget/agent.rs +++ b/codex-rs/tui/src/chatwidget/agent.rs @@ -4,9 +4,9 @@ use codex_core::CodexThread; use codex_core::NewThread; use codex_core::ThreadManager; use codex_core::config::Config; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::unbounded_channel; @@ -44,10 +44,10 @@ pub(crate) fn spawn_agent( }; // Forward the captured `SessionConfigured` event so it can be rendered in the UI. - let ev = codex_core::protocol::Event { + let ev = codex_protocol::protocol::Event { // The `id` does not matter for rendering, so we can use a fake value. id: "".to_string(), - msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), + msg: codex_protocol::protocol::EventMsg::SessionConfigured(session_configured), }; app_event_tx_clone.send(AppEvent::CodexEvent(ev)); @@ -80,7 +80,7 @@ pub(crate) fn spawn_agent( /// events and accepts Ops for submission. pub(crate) fn spawn_agent_from_existing( thread: std::sync::Arc, - session_configured: codex_core::protocol::SessionConfiguredEvent, + session_configured: codex_protocol::protocol::SessionConfiguredEvent, app_event_tx: AppEventSender, ) -> UnboundedSender { let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); @@ -88,9 +88,9 @@ pub(crate) fn spawn_agent_from_existing( let app_event_tx_clone = app_event_tx; tokio::spawn(async move { // Forward the captured `SessionConfigured` event so it can be rendered in the UI. - let ev = codex_core::protocol::Event { + let ev = codex_protocol::protocol::Event { id: "".to_string(), - msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), + msg: codex_protocol::protocol::EventMsg::SessionConfigured(session_configured), }; app_event_tx_clone.send(AppEvent::CodexEvent(ev)); diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index fccd438434..8216398448 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -1,13 +1,13 @@ use std::collections::VecDeque; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::PatchApplyEndEvent; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::McpToolCallBeginEvent; +use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::PatchApplyEndEvent; use codex_protocol::request_user_input::RequestUserInputEvent; use super::ChatWidget; diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index e7fadde1af..1d71cc61ec 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -14,13 +14,13 @@ use crate::skills_helpers::skill_description; use crate::skills_helpers::skill_display_name; use codex_chatgpt::connectors::AppInfo; use codex_core::connectors::connector_mention_slug; -use codex_core::protocol::ListSkillsResponseEvent; -use codex_core::protocol::SkillMetadata as ProtocolSkillMetadata; -use codex_core::protocol::SkillsListEntry; use codex_core::skills::model::SkillDependencies; use codex_core::skills::model::SkillInterface; use codex_core::skills::model::SkillMetadata; use codex_core::skills::model::SkillToolDependency; +use codex_protocol::protocol::ListSkillsResponseEvent; +use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; +use codex_protocol::protocol::SkillsListEntry; impl ChatWidget { pub(crate) fn open_skills_list(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f04d02f658..b9f5ddf814 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,6 +1,6 @@ //! Exercises `ChatWidget` event handling and rendering invariants. //! -//! These tests treat the widget as the adapter between `codex_core::protocol::EventMsg` inputs and +//! These tests treat the widget as the adapter between `codex_protocol::protocol::EventMsg` inputs and //! the TUI output. Many assertions are snapshot-based so that layout regressions and status/header //! changes show up as stable, reviewable diffs. @@ -25,47 +25,6 @@ use codex_core::config::types::WindowsSandboxModeToml; use codex_core::config_loader::RequirementSource; use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; -use codex_core::protocol::AgentMessageDeltaEvent; -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::AgentReasoningDeltaEvent; -use codex_core::protocol::AgentReasoningEvent; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::BackgroundEventEvent; -use codex_core::protocol::CreditsSnapshot; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::ExecCommandSource; -use codex_core::protocol::ExecCommandStatus as CoreExecCommandStatus; -use codex_core::protocol::ExecPolicyAmendment; -use codex_core::protocol::ExitedReviewModeEvent; -use codex_core::protocol::FileChange; -use codex_core::protocol::ItemCompletedEvent; -use codex_core::protocol::McpStartupCompleteEvent; -use codex_core::protocol::McpStartupStatus; -use codex_core::protocol::McpStartupUpdateEvent; -use codex_core::protocol::Op; -use codex_core::protocol::PatchApplyBeginEvent; -use codex_core::protocol::PatchApplyEndEvent; -use codex_core::protocol::PatchApplyStatus as CorePatchApplyStatus; -use codex_core::protocol::RateLimitWindow; -use codex_core::protocol::ReviewRequest; -use codex_core::protocol::ReviewTarget; -use codex_core::protocol::SessionSource; -use codex_core::protocol::StreamErrorEvent; -use codex_core::protocol::TerminalInteractionEvent; -use codex_core::protocol::ThreadRolledBackEvent; -use codex_core::protocol::TokenCountEvent; -use codex_core::protocol::TokenUsage; -use codex_core::protocol::TokenUsageInfo; -use codex_core::protocol::TurnCompleteEvent; -use codex_core::protocol::TurnStartedEvent; -use codex_core::protocol::UndoCompletedEvent; -use codex_core::protocol::UndoStartedEvent; -use codex_core::protocol::ViewImageToolCallEvent; -use codex_core::protocol::WarningEvent; use codex_core::skills::model::SkillMetadata; use codex_core::terminal::TerminalName; use codex_otel::OtelManager; @@ -87,8 +46,49 @@ use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::AgentMessageDeltaEvent; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::AgentReasoningDeltaEvent; +use codex_protocol::protocol::AgentReasoningEvent; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::BackgroundEventEvent; use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::ExecCommandSource; +use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; +use codex_protocol::protocol::ExecPolicyAmendment; +use codex_protocol::protocol::ExitedReviewModeEvent; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::McpStartupCompleteEvent; +use codex_protocol::protocol::McpStartupStatus; +use codex_protocol::protocol::McpStartupUpdateEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::PatchApplyBeginEvent; +use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; +use codex_protocol::protocol::RateLimitWindow; +use codex_protocol::protocol::ReviewRequest; +use codex_protocol::protocol::ReviewTarget; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::TerminalInteractionEvent; +use codex_protocol::protocol::ThreadRolledBackEvent; +use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::protocol::UndoCompletedEvent; +use codex_protocol::protocol::UndoStartedEvent; +use codex_protocol::protocol::ViewImageToolCallEvent; +use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; @@ -149,7 +149,7 @@ async fn resumed_initial_messages_render_history() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -218,7 +218,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -277,7 +277,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -333,7 +333,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -384,7 +384,7 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -494,7 +494,7 @@ async fn submission_preserves_text_elements_and_local_images() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -576,7 +576,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -669,7 +669,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -731,7 +731,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -769,7 +769,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -807,7 +807,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -846,7 +846,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, thread_name: None, @@ -1119,7 +1119,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { // must be renumbered to match the combined local image list. chat.handle_codex_event(Event { id: "interrupt".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, }), @@ -1184,7 +1184,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { chat.handle_codex_event(Event { id: "interrupt".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, }), @@ -2852,7 +2852,8 @@ fn begin_exec_with_source( // Build the full command vec and parse it using core's parser, // then convert to protocol variants for the event payload. let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; - let parsed_cmd: Vec = codex_core::parse_command::parse_command(&command); + let parsed_cmd: Vec = + codex_shell_command::parse_command::parse_command(&command); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let interaction_input = None; let event = ExecCommandBeginEvent { @@ -3494,7 +3495,7 @@ async fn exec_end_without_begin_uses_event_command() { "-lc".to_string(), "echo orphaned".to_string(), ]; - let parsed_cmd = codex_core::parse_command::parse_command(&command); + let parsed_cmd = codex_shell_command::parse_command::parse_command(&command); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); chat.handle_codex_event(Event { id: "call-orphan".to_string(), @@ -4037,7 +4038,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - let configured = codex_core::protocol::SessionConfiguredEvent { + let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, thread_name: None, @@ -4639,7 +4640,7 @@ async fn interrupt_exec_marks_failed_snapshot() { // cause the active exec cell to be finalized as failed and flushed. chat.handle_codex_event(Event { id: "call-int".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, }), @@ -4675,7 +4676,7 @@ async fn interrupted_turn_error_message_snapshot() { // Abort the turn (like pressing Esc) and drain inserted history. chat.handle_codex_event(Event { id: "task-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, }), @@ -6330,7 +6331,7 @@ async fn interrupt_restores_queued_messages_into_composer() { // Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed). chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, }), @@ -6369,7 +6370,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, }), @@ -6398,7 +6399,7 @@ async fn interrupt_clears_unified_exec_processes() { chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, }), @@ -6419,7 +6420,7 @@ async fn review_ended_keeps_unified_exec_processes() { chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, }), @@ -6464,7 +6465,7 @@ async fn interrupt_clears_unified_exec_wait_streak_snapshot() { chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, }), @@ -6572,7 +6573,7 @@ async fn ui_snapshots_small_heights_task_running() { // task (status indicator active) while an approval request is shown. #[tokio::test] async fn status_widget_and_approval_modal_snapshot() { - use codex_core::protocol::ExecApprovalRequestEvent; + use codex_protocol::protocol::ExecApprovalRequestEvent; let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; // Begin a running task so the status indicator would be active. @@ -6955,7 +6956,7 @@ async fn apply_patch_approval_sends_op_with_call_id() { while let Ok(app_ev) = rx.try_recv() { if let AppEvent::CodexOp(Op::PatchApproval { id, decision }) = app_ev { assert_eq!(id, "call-999"); - assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); + assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved); found = true; break; } @@ -7003,7 +7004,7 @@ async fn apply_patch_full_flow_integration_like() { match forwarded { Op::PatchApproval { id, decision } => { assert_eq!(id, "call-1"); - assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); + assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved); } other => panic!("unexpected op forwarded: {other:?}"), } @@ -7344,7 +7345,7 @@ async fn status_line_branch_refreshes_after_interrupt() { chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, }), diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 38faf2d35b..a3e98d85c7 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -9,7 +9,7 @@ use codex_core::config_loader::RequirementSource; use codex_core::config_loader::ResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement; use codex_core::config_loader::WebSearchModeRequirement; -use codex_core::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SessionNetworkProxyRuntime; use ratatui::style::Stylize; use ratatui::text::Line; use toml::Value as TomlValue; @@ -404,9 +404,9 @@ mod tests { use codex_core::config_loader::SandboxModeRequirement; use codex_core::config_loader::Sourced; use codex_core::config_loader::WebSearchModeRequirement; - use codex_core::protocol::AskForApproval; - use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::WebSearchMode; + use codex_protocol::protocol::AskForApproval; + use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::text::Line; use std::collections::BTreeMap; diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 9683df53ba..6fdf3a43f5 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -19,7 +19,7 @@ use crate::render::renderable::ColumnRenderable; use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; use codex_core::git_info::get_git_repo_root; -use codex_core::protocol::FileChange; +use codex_protocol::protocol::FileChange; // Internal representation for diff line rendering enum DiffLineType { diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index 21799c6199..f08b088ef7 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -1,8 +1,8 @@ use std::time::Duration; use std::time::Instant; -use codex_core::protocol::ExecCommandSource; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::ExecCommandSource; #[derive(Clone, Debug, Default)] pub(crate) struct CommandOutput { diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 4f7b1cd635..6e57c21520 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -13,9 +13,9 @@ use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; use codex_ansi_escape::ansi_escape_line; -use codex_core::bash::extract_bash_command; -use codex_core::protocol::ExecCommandSource; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::ExecCommandSource; +use codex_shell_command::bash::extract_bash_command; use codex_utils_elapsed::format_duration; use itertools::Itertools; use ratatui::prelude::*; @@ -611,7 +611,7 @@ const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new( #[cfg(test)] mod tests { use super::*; - use codex_core::protocol::ExecCommandSource; + use codex_protocol::protocol::ExecCommandSource; #[test] fn user_shell_output_is_limited_by_screen_lines() { diff --git a/codex-rs/tui/src/exec_command.rs b/codex-rs/tui/src/exec_command.rs index 8ce6c2632e..bcfbc1776d 100644 --- a/codex-rs/tui/src/exec_command.rs +++ b/codex-rs/tui/src/exec_command.rs @@ -1,7 +1,7 @@ use std::path::Path; use std::path::PathBuf; -use codex_core::parse_command::extract_shell_command; +use codex_shell_command::parse_command::extract_shell_command; use dirs::home_dir; use shlex::try_join; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8c4d0147df..bd08c370e4 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -39,10 +39,6 @@ use crate::wrapping::word_wrap_lines; use base64::Engine; use codex_core::config::Config; use codex_core::config::types::McpServerTransportConfig; -use codex_core::protocol::FileChange; -use codex_core::protocol::McpAuthStatus; -use codex_core::protocol::McpInvocation; -use codex_core::protocol::SessionConfiguredEvent; use codex_core::web_search::web_search_detail; use codex_otel::RuntimeMetricsSummary; use codex_protocol::account::PlanType; @@ -54,6 +50,10 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::McpAuthStatus; +use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::user_input::TextElement; @@ -791,9 +791,9 @@ fn exec_snippet(command: &[String]) -> String { pub fn new_approval_decision_cell( command: Vec, - decision: codex_core::protocol::ReviewDecision, + decision: codex_protocol::protocol::ReviewDecision, ) -> Box { - use codex_core::protocol::ReviewDecision::*; + use codex_protocol::protocol::ReviewDecision::*; let (symbol, summary): (Span<'static>, Vec>) = match decision { Approved => { @@ -2383,19 +2383,19 @@ mod tests { use codex_core::config::ConfigBuilder; use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; - use codex_core::protocol::McpAuthStatus; use codex_otel::RuntimeMetricTotals; use codex_otel::RuntimeMetricsSummary; use codex_protocol::models::WebSearchAction; use codex_protocol::parse_command::ParsedCommand; + use codex_protocol::protocol::McpAuthStatus; use dirs::home_dir; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashMap; - use codex_core::protocol::ExecCommandSource; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::Tool; + use codex_protocol::protocol::ExecCommandSource; use rmcp::model::Content; const SMALL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 20c9fc21b0..c44b5eb751 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -30,13 +30,13 @@ use codex_core::find_thread_path_by_id_str; use codex_core::find_thread_path_by_name_str; use codex_core::format_exec_policy_error_with_source; use codex_core::path_utils; -use codex_core::protocol::AskForApproval; use codex_core::read_session_meta_line; use codex_core::terminal::Multiplexer; use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_state::log_db; @@ -468,7 +468,7 @@ async fn run_ratatui_app( UpdatePromptOutcome::RunUpdate(action) => { crate::tui::restore()?; return Ok(AppExitInfo { - token_usage: codex_core::protocol::TokenUsage::default(), + token_usage: codex_protocol::protocol::TokenUsage::default(), thread_id: None, thread_name: None, update_action: Some(action), @@ -511,7 +511,7 @@ async fn run_ratatui_app( session_log::log_session_end(); let _ = tui.terminal.clear(); return Ok(AppExitInfo { - token_usage: codex_core::protocol::TokenUsage::default(), + token_usage: codex_protocol::protocol::TokenUsage::default(), thread_id: None, thread_name: None, update_action: None, @@ -551,7 +551,7 @@ async fn run_ratatui_app( session_log::log_session_end(); let _ = tui.terminal.clear(); Ok(AppExitInfo { - token_usage: codex_core::protocol::TokenUsage::default(), + token_usage: codex_protocol::protocol::TokenUsage::default(), thread_id: None, thread_name: None, update_action: None, @@ -600,7 +600,7 @@ async fn run_ratatui_app( restore(); session_log::log_session_end(); return Ok(AppExitInfo { - token_usage: codex_core::protocol::TokenUsage::default(), + token_usage: codex_protocol::protocol::TokenUsage::default(), thread_id: None, thread_name: None, update_action: None, @@ -651,7 +651,7 @@ async fn run_ratatui_app( restore(); session_log::log_session_end(); return Ok(AppExitInfo { - token_usage: codex_core::protocol::TokenUsage::default(), + token_usage: codex_protocol::protocol::TokenUsage::default(), thread_id: None, thread_name: None, update_action: None, @@ -681,7 +681,7 @@ async fn run_ratatui_app( restore(); session_log::log_session_end(); return Ok(AppExitInfo { - token_usage: codex_core::protocol::TokenUsage::default(), + token_usage: codex_protocol::protocol::TokenUsage::default(), thread_id: None, thread_name: None, update_action: None, @@ -965,7 +965,7 @@ mod tests { use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::ProjectConfig; - use codex_core::protocol::AskForApproval; + use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionMeta; diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 7f043ef0f9..b7a27576e8 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -24,7 +24,10 @@ fn main() -> anyhow::Result<()> { let exit_info = run_main(inner, codex_linux_sandbox_exe).await?; let token_usage = exit_info.token_usage; if !token_usage.is_zero() { - println!("{}", codex_core::protocol::FinalOutput::from(token_usage),); + println!( + "{}", + codex_protocol::protocol::FinalOutput::from(token_usage), + ); } Ok(()) }) diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 57df8b8891..806400f2e3 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -1,17 +1,17 @@ use crate::history_cell::PlainHistoryCell; use crate::render::line_utils::prefix_lines; use crate::text_formatting::truncate_text; -use codex_core::protocol::AgentStatus; -use codex_core::protocol::CollabAgentInteractionEndEvent; -use codex_core::protocol::CollabAgentRef; -use codex_core::protocol::CollabAgentSpawnEndEvent; -use codex_core::protocol::CollabAgentStatusEntry; -use codex_core::protocol::CollabCloseEndEvent; -use codex_core::protocol::CollabResumeBeginEvent; -use codex_core::protocol::CollabResumeEndEvent; -use codex_core::protocol::CollabWaitingBeginEvent; -use codex_core::protocol::CollabWaitingEndEvent; use codex_protocol::ThreadId; +use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::CollabAgentInteractionEndEvent; +use codex_protocol::protocol::CollabAgentRef; +use codex_protocol::protocol::CollabAgentSpawnEndEvent; +use codex_protocol::protocol::CollabAgentStatusEntry; +use codex_protocol::protocol::CollabCloseEndEvent; +use codex_protocol::protocol::CollabResumeBeginEvent; +use codex_protocol::protocol::CollabResumeEndEvent; +use codex_protocol::protocol::CollabWaitingBeginEvent; +use codex_protocol::protocol::CollabWaitingEndEvent; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index f04310cd86..00c18dd71a 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -796,8 +796,8 @@ fn render_offset_content( #[cfg(test)] mod tests { use super::*; - use codex_core::protocol::ExecCommandSource; - use codex_core::protocol::ReviewDecision; + use codex_protocol::protocol::ExecCommandSource; + use codex_protocol::protocol::ReviewDecision; use insta::assert_snapshot; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -809,8 +809,8 @@ mod tests { use crate::history_cell; use crate::history_cell::HistoryCell; use crate::history_cell::new_patch_event; - use codex_core::protocol::FileChange; use codex_protocol::parse_command::ParsedCommand; + use codex_protocol::protocol::FileChange; use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::text::Text; diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index b2858e8f21..ca4d39cd18 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -7,7 +7,7 @@ use std::sync::Mutex; use std::sync::OnceLock; use codex_core::config::Config; -use codex_core::protocol::Op; +use codex_protocol::protocol::Op; use serde::Serialize; use serde_json::json; diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index e6d5a27d29..bf55c0ace1 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -7,14 +7,14 @@ use chrono::DateTime; use chrono::Local; use codex_core::WireApi; use codex_core::config::Config; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::NetworkAccess; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol::TokenUsage; -use codex_core::protocol::TokenUsageInfo; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::NetworkAccess; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; use codex_utils_sandbox_summary::summarize_sandbox_policy; use ratatui::prelude::*; use ratatui::style::Stylize; diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 8d46bc8c99..10dbaaaa2f 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -13,9 +13,9 @@ use chrono::DateTime; use chrono::Duration as ChronoDuration; use chrono::Local; use chrono::Utc; -use codex_core::protocol::CreditsSnapshot as CoreCreditsSnapshot; -use codex_core::protocol::RateLimitSnapshot; -use codex_core::protocol::RateLimitWindow; +use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow; const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; const STATUS_LIMIT_BAR_FILLED: &str = "█"; diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 02838c651e..fb1aad7275 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -7,16 +7,16 @@ use chrono::Utc; use codex_core::AuthManager; use codex_core::config::Config; use codex_core::config::ConfigBuilder; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::CreditsSnapshot; -use codex_core::protocol::RateLimitSnapshot; -use codex_core::protocol::RateLimitWindow; -use codex_core::protocol::SandboxPolicy; -use codex_core::protocol::TokenUsage; -use codex_core::protocol::TokenUsageInfo; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::*; diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index b41a2fa3ca..fa266f9760 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -7,7 +7,7 @@ use std::time::Duration; use std::time::Instant; -use codex_core::protocol::Op; +use codex_protocol::protocol::Op; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; diff --git a/codex-rs/utils/approval-presets/Cargo.toml b/codex-rs/utils/approval-presets/Cargo.toml index 1237545cd6..0e387f07a8 100644 --- a/codex-rs/utils/approval-presets/Cargo.toml +++ b/codex-rs/utils/approval-presets/Cargo.toml @@ -8,4 +8,4 @@ license.workspace = true workspace = true [dependencies] -codex-core = { workspace = true } +codex-protocol = { workspace = true } diff --git a/codex-rs/utils/approval-presets/src/lib.rs b/codex-rs/utils/approval-presets/src/lib.rs index 9e66e39e08..fbfa120e61 100644 --- a/codex-rs/utils/approval-presets/src/lib.rs +++ b/codex-rs/utils/approval-presets/src/lib.rs @@ -1,5 +1,5 @@ -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; /// A simple preset pairing an approval policy with a sandbox policy. #[derive(Debug, Clone)] diff --git a/codex-rs/utils/cli/Cargo.toml b/codex-rs/utils/cli/Cargo.toml index 7e9d2c5a1d..d9adf42b3f 100644 --- a/codex-rs/utils/cli/Cargo.toml +++ b/codex-rs/utils/cli/Cargo.toml @@ -9,7 +9,6 @@ workspace = true [dependencies] clap = { workspace = true, features = ["derive", "wrap_help"] } -codex-core = { workspace = true } codex-protocol = { workspace = true } serde = { workspace = true } toml = { workspace = true } diff --git a/codex-rs/utils/cli/src/approval_mode_cli_arg.rs b/codex-rs/utils/cli/src/approval_mode_cli_arg.rs index 08b7be6cca..ee4ecabcb6 100644 --- a/codex-rs/utils/cli/src/approval_mode_cli_arg.rs +++ b/codex-rs/utils/cli/src/approval_mode_cli_arg.rs @@ -2,7 +2,7 @@ use clap::ValueEnum; -use codex_core::protocol::AskForApproval; +use codex_protocol::protocol::AskForApproval; #[derive(Clone, Copy, Debug, ValueEnum)] #[value(rename_all = "kebab-case")] diff --git a/codex-rs/utils/cli/src/sandbox_mode_cli_arg.rs b/codex-rs/utils/cli/src/sandbox_mode_cli_arg.rs index 18935840f4..b7d1a248b8 100644 --- a/codex-rs/utils/cli/src/sandbox_mode_cli_arg.rs +++ b/codex-rs/utils/cli/src/sandbox_mode_cli_arg.rs @@ -1,6 +1,6 @@ //! Standard type to use with the `--sandbox` (`-s`) CLI option. //! -//! This mirrors the variants of [`codex_core::protocol::SandboxPolicy`], but +//! This mirrors the variants of [`codex_protocol::protocol::SandboxPolicy`], but //! without any of the associated data so it can be expressed as a simple flag //! on the command-line. Users that need to tweak the advanced options for //! `workspace-write` can continue to do so via `-c` overrides or their diff --git a/codex-rs/utils/sandbox-summary/Cargo.toml b/codex-rs/utils/sandbox-summary/Cargo.toml index 6e44b2d201..202dfaf22b 100644 --- a/codex-rs/utils/sandbox-summary/Cargo.toml +++ b/codex-rs/utils/sandbox-summary/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] codex-core = { workspace = true } +codex-protocol = { workspace = true } [dev-dependencies] codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs index 53851d86b7..a5c9b31fdd 100644 --- a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs +++ b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs @@ -1,5 +1,5 @@ -use codex_core::protocol::NetworkAccess; -use codex_core::protocol::SandboxPolicy; +use codex_protocol::protocol::NetworkAccess; +use codex_protocol::protocol::SandboxPolicy; pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { match sandbox_policy { From 2fe4be1aa98b1706ac911ebaebc16a6c31407dcb Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 21 Feb 2026 00:20:42 -0800 Subject: [PATCH 033/282] fix: codex-arg0 no longer depends on codex-core (#12434) ## Why `codex-rs/arg0` only needed two things from `codex-core`: - the `find_codex_home()` wrapper - the special argv flag used for the internal `apply_patch` self-invocation path That made `codex-arg0` depend on `codex-core` for a very small surface area. This change removes that dependency edge and moves the shared `apply_patch` invocation flag to a more natural boundary (`codex-apply-patch`) while keeping the contract explicitly documented. ## What Changed - Moved the internal `apply_patch` argv[1] flag constant out of `codex-core` and into `codex-apply-patch`. - Renamed the constant to `CODEX_CORE_APPLY_PATCH_ARG1` and documented that it is part of the Codex core process-invocation contract (even though it now lives in `codex-apply-patch`). - Updated `arg0`, the core apply-patch runtime, and the `codex-exec` apply-patch test to import the constant from `codex-apply-patch`. - Updated `codex-rs/arg0` to call `codex_utils_home_dir::find_codex_home()` directly instead of `codex_core::config::find_codex_home()`. - Removed the `codex-core` dependency from `codex-rs/arg0` and added the needed direct dependency on `codex-utils-home-dir`. - Added `codex-apply-patch` as a dev-dependency for `codex-rs/exec` tests (the apply-patch test now imports the moved constant directly). ## Verification - `cargo test -p codex-apply-patch` - `cargo test -p codex-arg0` - `cargo test -p codex-core --lib apply_patch` - `cargo test -p codex-exec test_standalone_exec_cli_can_use_apply_patch` - `cargo shear` --- codex-rs/Cargo.lock | 3 ++- codex-rs/apply-patch/src/lib.rs | 9 +++++++++ codex-rs/arg0/Cargo.toml | 2 +- codex-rs/arg0/src/lib.rs | 13 +++++++------ codex-rs/core/src/apply_patch.rs | 5 ++--- codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/tools/runtimes/apply_patch.rs | 7 +++++-- codex-rs/exec/Cargo.toml | 1 + codex-rs/exec/tests/suite/apply_patch.rs | 4 ++-- 9 files changed, 29 insertions(+), 16 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e9fd6ed167..42521a48df 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1398,8 +1398,8 @@ version = "0.0.0" dependencies = [ "anyhow", "codex-apply-patch", - "codex-core", "codex-linux-sandbox", + "codex-utils-home-dir", "dotenvy", "tempfile", "tokio", @@ -1735,6 +1735,7 @@ dependencies = [ "anyhow", "assert_cmd", "clap", + "codex-apply-patch", "codex-arg0", "codex-cloud-requirements", "codex-core", diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index f58055f450..1fc60474b9 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -25,6 +25,15 @@ use crate::invocation::ExtractHeredocError; /// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool. pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md"); +/// Special argv[1] flag used when the Codex executable self-invokes to run the +/// internal `apply_patch` path. +/// +/// Although this constant lives in `codex-apply-patch` (to avoid forcing +/// `codex-arg0` to depend on `codex-core`), it is part of the "codex core" +/// process-invocation contract between the apply-patch runtime and the arg0 +/// dispatcher. +pub const CODEX_CORE_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch"; + #[derive(Debug, Error, PartialEq)] pub enum ApplyPatchError { #[error(transparent)] diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index c82bdd58da..c5d9681327 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -14,8 +14,8 @@ workspace = true [dependencies] anyhow = { workspace = true } codex-apply-patch = { workspace = true } -codex-core = { workspace = true } codex-linux-sandbox = { workspace = true } +codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index dd89baeff3..3ac7e017f2 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -3,7 +3,8 @@ use std::future::Future; use std::path::Path; use std::path::PathBuf; -use codex_core::CODEX_APPLY_PATCH_ARG1; +use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; +use codex_utils_home_dir::find_codex_home; #[cfg(unix)] use std::os::unix::fs::symlink; use tempfile::TempDir; @@ -46,7 +47,7 @@ pub fn arg0_dispatch() -> Option { } let argv1 = args.next().unwrap_or_default(); - if argv1 == CODEX_APPLY_PATCH_ARG1 { + if argv1 == CODEX_CORE_APPLY_PATCH_ARG1 { let patch_arg = args.next().and_then(|s| s.to_str().map(str::to_owned)); let exit_code = match patch_arg { Some(patch_arg) => { @@ -58,7 +59,7 @@ pub fn arg0_dispatch() -> Option { } } None => { - eprintln!("Error: {CODEX_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument."); + eprintln!("Error: {CODEX_CORE_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument."); 1 } }; @@ -139,7 +140,7 @@ const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_"; /// Security: Do not allow `.env` files to create or modify any variables /// with names starting with `CODEX_`. fn load_dotenv() { - if let Ok(codex_home) = codex_core::config::find_codex_home() + if let Ok(codex_home) = find_codex_home() && let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) { set_filtered(iter); @@ -175,7 +176,7 @@ where /// IMPORTANT: This function modifies the PATH environment variable, so it MUST /// be called before multiple threads are spawned. pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { - let codex_home = codex_core::config::find_codex_home()?; + let codex_home = find_codex_home()?; #[cfg(not(debug_assertions))] { // Guard against placing helpers in system temp directories outside debug builds. @@ -242,7 +243,7 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result anyhow::Result<()> { fs::write(&absolute_path, "original content\n")?; Command::new(codex_utils_cargo_bin::cargo_bin("codex-exec")?) - .arg(CODEX_APPLY_PATCH_ARG1) + .arg(CODEX_CORE_APPLY_PATCH_ARG1) .arg( r#"*** Begin Patch *** Update File: source.txt From 85ce91a5b329f1e999eca87354e8b80e2ba74f3d Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 21 Feb 2026 00:34:08 -0800 Subject: [PATCH 034/282] refactor(core): move embedded system skills into codex-skills crate (#12435) ## Why `codex-core` was carrying the embedded system-skill sample assets (and a `build.rs` that walks those files to register rerun triggers). Those assets change infrequently, but any change under `codex-core` still ties them to `codex-core`'s build/cache lifecycle. This change moves the embedded system-skills packaging into a dedicated `codex-skills` crate so it can be cached independently. That reduces unnecessary invalidation/rebuild pressure on `codex-core` when the skills bundle is the only thing that changes. ## What Changed - Added a new `codex-rs/skills` crate (`codex-skills`) with: - `Cargo.toml` - `BUILD.bazel` - `build.rs` to track skill asset file changes for Cargo rebuilds - `src/lib.rs` containing the embedded system-skills install/cache logic previously in `codex-core` - Moved the embedded sample skill assets from `codex-rs/core/src/skills/assets/samples` to `codex-rs/skills/src/assets/samples`. - Updated `codex-rs/core/Cargo.toml` to depend on `codex-skills` and removed `codex-core`'s direct `include_dir` dependency. - Removed `codex-core`'s `build.rs`. - Replaced `codex-rs/core/src/skills/system.rs` implementation with a thin re-export wrapper to keep existing `codex-core` call sites unchanged. - Updated workspace manifests/lockfile (`codex-rs/Cargo.toml`, `codex-rs/Cargo.lock`) for the new crate. --- codex-rs/Cargo.lock | 11 +- codex-rs/Cargo.toml | 2 + codex-rs/core/Cargo.toml | 3 +- codex-rs/core/src/skills/system.rs | 198 +----------------- codex-rs/skills/BUILD.bazel | 15 ++ codex-rs/skills/Cargo.toml | 19 ++ codex-rs/{core => skills}/build.rs | 2 +- .../assets/samples/skill-creator/SKILL.md | 0 .../samples/skill-creator/agents/openai.yaml | 0 .../assets/skill-creator-small.svg | 0 .../skill-creator/assets/skill-creator.png | Bin .../assets/samples/skill-creator/license.txt | 0 .../skill-creator/references/openai_yaml.md | 0 .../scripts/generate_openai_yaml.py | 0 .../skill-creator/scripts/init_skill.py | 0 .../skill-creator/scripts/quick_validate.py | 0 .../samples/skill-installer/LICENSE.txt | 0 .../assets/samples/skill-installer/SKILL.md | 0 .../skill-installer/agents/openai.yaml | 0 .../assets/skill-installer-small.svg | 0 .../assets/skill-installer.png | Bin .../skill-installer/scripts/github_utils.py | 0 .../scripts/install-skill-from-github.py | 0 .../skill-installer/scripts/list-skills.py | 0 codex-rs/skills/src/lib.rs | 195 +++++++++++++++++ 25 files changed, 245 insertions(+), 200 deletions(-) create mode 100644 codex-rs/skills/BUILD.bazel create mode 100644 codex-rs/skills/Cargo.toml rename codex-rs/{core => skills}/build.rs (89%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/SKILL.md (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/agents/openai.yaml (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/assets/skill-creator-small.svg (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/assets/skill-creator.png (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/license.txt (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/references/openai_yaml.md (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/scripts/generate_openai_yaml.py (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/scripts/init_skill.py (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-creator/scripts/quick_validate.py (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/LICENSE.txt (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/SKILL.md (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/agents/openai.yaml (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/assets/skill-installer-small.svg (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/assets/skill-installer.png (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/scripts/github_utils.py (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/scripts/install-skill-from-github.py (100%) rename codex-rs/{core/src/skills => skills/src}/assets/samples/skill-installer/scripts/list-skills.py (100%) create mode 100644 codex-rs/skills/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 42521a48df..03276a7adb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1644,6 +1644,7 @@ dependencies = [ "codex-rmcp-client", "codex-secrets", "codex-shell-command", + "codex-skills", "codex-state", "codex-utils-absolute-path", "codex-utils-cargo-bin", @@ -1663,7 +1664,6 @@ dependencies = [ "futures", "http 1.4.0", "image", - "include_dir", "indexmap 2.13.0", "indoc", "insta", @@ -2189,6 +2189,15 @@ dependencies = [ "which", ] +[[package]] +name = "codex-skills" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "include_dir", + "thiserror 2.0.18", +] + [[package]] name = "codex-state" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 8ff0c456b8..d23ed3c27b 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -17,6 +17,7 @@ members = [ "cli", "config", "shell-command", + "skills", "core", "hooks", "secrets", @@ -112,6 +113,7 @@ codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-secrets = { path = "secrets" } codex-shell-command = { path = "shell-command" } +codex-skills = { path = "skills" } codex-state = { path = "state" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index bda19d78ea..15f9d7d11c 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -3,7 +3,6 @@ edition.workspace = true license.workspace = true name = "codex-core" version.workspace = true -build = "build.rs" [lib] doctest = false @@ -34,6 +33,7 @@ codex-async-utils = { workspace = true } codex-client = { workspace = true } codex-config = { workspace = true } codex-shell-command = { workspace = true } +codex-skills = { workspace = true } codex-execpolicy = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } @@ -58,7 +58,6 @@ env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } -include_dir = { workspace = true } indexmap = { workspace = true } indoc = { workspace = true } keyring = { workspace = true, features = ["crypto-rust"] } diff --git a/codex-rs/core/src/skills/system.rs b/codex-rs/core/src/skills/system.rs index cf8404096d..42b48ea3a5 100644 --- a/codex-rs/core/src/skills/system.rs +++ b/codex-rs/core/src/skills/system.rs @@ -1,196 +1,2 @@ -use codex_utils_absolute_path::AbsolutePathBuf; -use include_dir::Dir; -use std::collections::hash_map::DefaultHasher; -use std::fs; -use std::hash::Hash; -use std::hash::Hasher; -use std::path::Path; -use std::path::PathBuf; - -use thiserror::Error; - -const SYSTEM_SKILLS_DIR: Dir = - include_dir::include_dir!("$CARGO_MANIFEST_DIR/src/skills/assets/samples"); - -const SYSTEM_SKILLS_DIR_NAME: &str = ".system"; -const SKILLS_DIR_NAME: &str = "skills"; -const SYSTEM_SKILLS_MARKER_FILENAME: &str = ".codex-system-skills.marker"; -const SYSTEM_SKILLS_MARKER_SALT: &str = "v1"; - -/// Returns the on-disk cache location for embedded system skills. -/// -/// This is typically located at `CODEX_HOME/skills/.system`. -pub(crate) fn system_cache_root_dir(codex_home: &Path) -> PathBuf { - AbsolutePathBuf::try_from(codex_home) - .and_then(|codex_home| system_cache_root_dir_abs(&codex_home)) - .map(AbsolutePathBuf::into_path_buf) - .unwrap_or_else(|_| { - codex_home - .join(SKILLS_DIR_NAME) - .join(SYSTEM_SKILLS_DIR_NAME) - }) -} - -fn system_cache_root_dir_abs(codex_home: &AbsolutePathBuf) -> std::io::Result { - codex_home - .join(SKILLS_DIR_NAME)? - .join(SYSTEM_SKILLS_DIR_NAME) -} - -/// Installs embedded system skills into `CODEX_HOME/skills/.system`. -/// -/// Clears any existing system skills directory first and then writes the embedded -/// skills directory into place. -/// -/// To avoid doing unnecessary work on every startup, a marker file is written -/// with a fingerprint of the embedded directory. When the marker matches, the -/// install is skipped. -pub(crate) fn install_system_skills(codex_home: &Path) -> Result<(), SystemSkillsError> { - let codex_home = AbsolutePathBuf::try_from(codex_home) - .map_err(|source| SystemSkillsError::io("normalize codex home dir", source))?; - let skills_root_dir = codex_home - .join(SKILLS_DIR_NAME) - .map_err(|source| SystemSkillsError::io("resolve skills root dir", source))?; - fs::create_dir_all(skills_root_dir.as_path()) - .map_err(|source| SystemSkillsError::io("create skills root dir", source))?; - - let dest_system = system_cache_root_dir_abs(&codex_home) - .map_err(|source| SystemSkillsError::io("resolve system skills cache root dir", source))?; - - let marker_path = dest_system - .join(SYSTEM_SKILLS_MARKER_FILENAME) - .map_err(|source| SystemSkillsError::io("resolve system skills marker path", source))?; - let expected_fingerprint = embedded_system_skills_fingerprint(); - if dest_system.as_path().is_dir() - && read_marker(&marker_path).is_ok_and(|marker| marker == expected_fingerprint) - { - return Ok(()); - } - - if dest_system.as_path().exists() { - fs::remove_dir_all(dest_system.as_path()) - .map_err(|source| SystemSkillsError::io("remove existing system skills dir", source))?; - } - - write_embedded_dir(&SYSTEM_SKILLS_DIR, &dest_system)?; - fs::write(marker_path.as_path(), format!("{expected_fingerprint}\n")) - .map_err(|source| SystemSkillsError::io("write system skills marker", source))?; - Ok(()) -} - -fn read_marker(path: &AbsolutePathBuf) -> Result { - Ok(fs::read_to_string(path.as_path()) - .map_err(|source| SystemSkillsError::io("read system skills marker", source))? - .trim() - .to_string()) -} - -fn embedded_system_skills_fingerprint() -> String { - let mut items = Vec::new(); - collect_fingerprint_items(&SYSTEM_SKILLS_DIR, &mut items); - items.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); - - let mut hasher = DefaultHasher::new(); - SYSTEM_SKILLS_MARKER_SALT.hash(&mut hasher); - for (path, contents_hash) in items { - path.hash(&mut hasher); - contents_hash.hash(&mut hasher); - } - format!("{:x}", hasher.finish()) -} - -fn collect_fingerprint_items(dir: &Dir<'_>, items: &mut Vec<(String, Option)>) { - for entry in dir.entries() { - match entry { - include_dir::DirEntry::Dir(subdir) => { - items.push((subdir.path().to_string_lossy().to_string(), None)); - collect_fingerprint_items(subdir, items); - } - include_dir::DirEntry::File(file) => { - let mut file_hasher = DefaultHasher::new(); - file.contents().hash(&mut file_hasher); - items.push(( - file.path().to_string_lossy().to_string(), - Some(file_hasher.finish()), - )); - } - } - } -} - -/// Writes the embedded `include_dir::Dir` to disk under `dest`. -/// -/// Preserves the embedded directory structure. -fn write_embedded_dir(dir: &Dir<'_>, dest: &AbsolutePathBuf) -> Result<(), SystemSkillsError> { - fs::create_dir_all(dest.as_path()) - .map_err(|source| SystemSkillsError::io("create system skills dir", source))?; - - for entry in dir.entries() { - match entry { - include_dir::DirEntry::Dir(subdir) => { - let subdir_dest = dest.join(subdir.path()).map_err(|source| { - SystemSkillsError::io("resolve system skills subdir", source) - })?; - fs::create_dir_all(subdir_dest.as_path()).map_err(|source| { - SystemSkillsError::io("create system skills subdir", source) - })?; - write_embedded_dir(subdir, dest)?; - } - include_dir::DirEntry::File(file) => { - let path = dest.join(file.path()).map_err(|source| { - SystemSkillsError::io("resolve system skills file", source) - })?; - if let Some(parent) = path.as_path().parent() { - fs::create_dir_all(parent).map_err(|source| { - SystemSkillsError::io("create system skills file parent", source) - })?; - } - fs::write(path.as_path(), file.contents()) - .map_err(|source| SystemSkillsError::io("write system skill file", source))?; - } - } - } - - Ok(()) -} - -#[derive(Debug, Error)] -pub(crate) enum SystemSkillsError { - #[error("io error while {action}: {source}")] - Io { - action: &'static str, - #[source] - source: std::io::Error, - }, -} - -impl SystemSkillsError { - fn io(action: &'static str, source: std::io::Error) -> Self { - Self::Io { action, source } - } -} - -#[cfg(test)] -mod tests { - use super::SYSTEM_SKILLS_DIR; - use super::collect_fingerprint_items; - - #[test] - fn fingerprint_traverses_nested_entries() { - let mut items = Vec::new(); - collect_fingerprint_items(&SYSTEM_SKILLS_DIR, &mut items); - let mut paths: Vec = items.into_iter().map(|(path, _)| path).collect(); - paths.sort_unstable(); - - assert!( - paths - .binary_search_by(|probe| probe.as_str().cmp("skill-creator/SKILL.md")) - .is_ok() - ); - assert!( - paths - .binary_search_by(|probe| probe.as_str().cmp("skill-creator/scripts/init_skill.py")) - .is_ok() - ); - } -} +pub(crate) use codex_skills::install_system_skills; +pub(crate) use codex_skills::system_cache_root_dir; diff --git a/codex-rs/skills/BUILD.bazel b/codex-rs/skills/BUILD.bazel new file mode 100644 index 0000000000..1c3fc16954 --- /dev/null +++ b/codex-rs/skills/BUILD.bazel @@ -0,0 +1,15 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "skills", + crate_name = "codex_skills", + compile_data = glob( + include = ["**"], + exclude = [ + "**/* *", + "BUILD.bazel", + "Cargo.toml", + ], + allow_empty = True, + ), +) diff --git a/codex-rs/skills/Cargo.toml b/codex-rs/skills/Cargo.toml new file mode 100644 index 0000000000..babb5a177a --- /dev/null +++ b/codex-rs/skills/Cargo.toml @@ -0,0 +1,19 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-skills" +version.workspace = true +build = "build.rs" + +[lib] +doctest = false +name = "codex_skills" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-utils-absolute-path = { workspace = true } +include_dir = { workspace = true } +thiserror = { workspace = true } diff --git a/codex-rs/core/build.rs b/codex-rs/skills/build.rs similarity index 89% rename from codex-rs/core/build.rs rename to codex-rs/skills/build.rs index 587415a3fd..db4ef68416 100644 --- a/codex-rs/core/build.rs +++ b/codex-rs/skills/build.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::Path; fn main() { - let samples_dir = Path::new("src/skills/assets/samples"); + let samples_dir = Path::new("src/assets/samples"); if !samples_dir.exists() { return; } diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/SKILL.md b/codex-rs/skills/src/assets/samples/skill-creator/SKILL.md similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/SKILL.md rename to codex-rs/skills/src/assets/samples/skill-creator/SKILL.md diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/agents/openai.yaml b/codex-rs/skills/src/assets/samples/skill-creator/agents/openai.yaml similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/agents/openai.yaml rename to codex-rs/skills/src/assets/samples/skill-creator/agents/openai.yaml diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator-small.svg b/codex-rs/skills/src/assets/samples/skill-creator/assets/skill-creator-small.svg similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator-small.svg rename to codex-rs/skills/src/assets/samples/skill-creator/assets/skill-creator-small.svg diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator.png b/codex-rs/skills/src/assets/samples/skill-creator/assets/skill-creator.png similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator.png rename to codex-rs/skills/src/assets/samples/skill-creator/assets/skill-creator.png diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/license.txt b/codex-rs/skills/src/assets/samples/skill-creator/license.txt similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/license.txt rename to codex-rs/skills/src/assets/samples/skill-creator/license.txt diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/references/openai_yaml.md b/codex-rs/skills/src/assets/samples/skill-creator/references/openai_yaml.md similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/references/openai_yaml.md rename to codex-rs/skills/src/assets/samples/skill-creator/references/openai_yaml.md diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/scripts/generate_openai_yaml.py b/codex-rs/skills/src/assets/samples/skill-creator/scripts/generate_openai_yaml.py similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/scripts/generate_openai_yaml.py rename to codex-rs/skills/src/assets/samples/skill-creator/scripts/generate_openai_yaml.py diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/scripts/init_skill.py b/codex-rs/skills/src/assets/samples/skill-creator/scripts/init_skill.py similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/scripts/init_skill.py rename to codex-rs/skills/src/assets/samples/skill-creator/scripts/init_skill.py diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/scripts/quick_validate.py b/codex-rs/skills/src/assets/samples/skill-creator/scripts/quick_validate.py similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-creator/scripts/quick_validate.py rename to codex-rs/skills/src/assets/samples/skill-creator/scripts/quick_validate.py diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/LICENSE.txt b/codex-rs/skills/src/assets/samples/skill-installer/LICENSE.txt similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/LICENSE.txt rename to codex-rs/skills/src/assets/samples/skill-installer/LICENSE.txt diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/SKILL.md b/codex-rs/skills/src/assets/samples/skill-installer/SKILL.md similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/SKILL.md rename to codex-rs/skills/src/assets/samples/skill-installer/SKILL.md diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/agents/openai.yaml b/codex-rs/skills/src/assets/samples/skill-installer/agents/openai.yaml similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/agents/openai.yaml rename to codex-rs/skills/src/assets/samples/skill-installer/agents/openai.yaml diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer-small.svg b/codex-rs/skills/src/assets/samples/skill-installer/assets/skill-installer-small.svg similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer-small.svg rename to codex-rs/skills/src/assets/samples/skill-installer/assets/skill-installer-small.svg diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer.png b/codex-rs/skills/src/assets/samples/skill-installer/assets/skill-installer.png similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer.png rename to codex-rs/skills/src/assets/samples/skill-installer/assets/skill-installer.png diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/scripts/github_utils.py b/codex-rs/skills/src/assets/samples/skill-installer/scripts/github_utils.py similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/scripts/github_utils.py rename to codex-rs/skills/src/assets/samples/skill-installer/scripts/github_utils.py diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/scripts/install-skill-from-github.py b/codex-rs/skills/src/assets/samples/skill-installer/scripts/install-skill-from-github.py similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/scripts/install-skill-from-github.py rename to codex-rs/skills/src/assets/samples/skill-installer/scripts/install-skill-from-github.py diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/scripts/list-skills.py b/codex-rs/skills/src/assets/samples/skill-installer/scripts/list-skills.py similarity index 100% rename from codex-rs/core/src/skills/assets/samples/skill-installer/scripts/list-skills.py rename to codex-rs/skills/src/assets/samples/skill-installer/scripts/list-skills.py diff --git a/codex-rs/skills/src/lib.rs b/codex-rs/skills/src/lib.rs new file mode 100644 index 0000000000..99cde06f6f --- /dev/null +++ b/codex-rs/skills/src/lib.rs @@ -0,0 +1,195 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use include_dir::Dir; +use std::collections::hash_map::DefaultHasher; +use std::fs; +use std::hash::Hash; +use std::hash::Hasher; +use std::path::Path; +use std::path::PathBuf; + +use thiserror::Error; + +const SYSTEM_SKILLS_DIR: Dir = include_dir::include_dir!("$CARGO_MANIFEST_DIR/src/assets/samples"); + +const SYSTEM_SKILLS_DIR_NAME: &str = ".system"; +const SKILLS_DIR_NAME: &str = "skills"; +const SYSTEM_SKILLS_MARKER_FILENAME: &str = ".codex-system-skills.marker"; +const SYSTEM_SKILLS_MARKER_SALT: &str = "v1"; + +/// Returns the on-disk cache location for embedded system skills. +/// +/// This is typically located at `CODEX_HOME/skills/.system`. +pub fn system_cache_root_dir(codex_home: &Path) -> PathBuf { + AbsolutePathBuf::try_from(codex_home) + .and_then(|codex_home| system_cache_root_dir_abs(&codex_home)) + .map(AbsolutePathBuf::into_path_buf) + .unwrap_or_else(|_| { + codex_home + .join(SKILLS_DIR_NAME) + .join(SYSTEM_SKILLS_DIR_NAME) + }) +} + +fn system_cache_root_dir_abs(codex_home: &AbsolutePathBuf) -> std::io::Result { + codex_home + .join(SKILLS_DIR_NAME)? + .join(SYSTEM_SKILLS_DIR_NAME) +} + +/// Installs embedded system skills into `CODEX_HOME/skills/.system`. +/// +/// Clears any existing system skills directory first and then writes the embedded +/// skills directory into place. +/// +/// To avoid doing unnecessary work on every startup, a marker file is written +/// with a fingerprint of the embedded directory. When the marker matches, the +/// install is skipped. +pub fn install_system_skills(codex_home: &Path) -> Result<(), SystemSkillsError> { + let codex_home = AbsolutePathBuf::try_from(codex_home) + .map_err(|source| SystemSkillsError::io("normalize codex home dir", source))?; + let skills_root_dir = codex_home + .join(SKILLS_DIR_NAME) + .map_err(|source| SystemSkillsError::io("resolve skills root dir", source))?; + fs::create_dir_all(skills_root_dir.as_path()) + .map_err(|source| SystemSkillsError::io("create skills root dir", source))?; + + let dest_system = system_cache_root_dir_abs(&codex_home) + .map_err(|source| SystemSkillsError::io("resolve system skills cache root dir", source))?; + + let marker_path = dest_system + .join(SYSTEM_SKILLS_MARKER_FILENAME) + .map_err(|source| SystemSkillsError::io("resolve system skills marker path", source))?; + let expected_fingerprint = embedded_system_skills_fingerprint(); + if dest_system.as_path().is_dir() + && read_marker(&marker_path).is_ok_and(|marker| marker == expected_fingerprint) + { + return Ok(()); + } + + if dest_system.as_path().exists() { + fs::remove_dir_all(dest_system.as_path()) + .map_err(|source| SystemSkillsError::io("remove existing system skills dir", source))?; + } + + write_embedded_dir(&SYSTEM_SKILLS_DIR, &dest_system)?; + fs::write(marker_path.as_path(), format!("{expected_fingerprint}\n")) + .map_err(|source| SystemSkillsError::io("write system skills marker", source))?; + Ok(()) +} + +fn read_marker(path: &AbsolutePathBuf) -> Result { + Ok(fs::read_to_string(path.as_path()) + .map_err(|source| SystemSkillsError::io("read system skills marker", source))? + .trim() + .to_string()) +} + +fn embedded_system_skills_fingerprint() -> String { + let mut items = Vec::new(); + collect_fingerprint_items(&SYSTEM_SKILLS_DIR, &mut items); + items.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); + + let mut hasher = DefaultHasher::new(); + SYSTEM_SKILLS_MARKER_SALT.hash(&mut hasher); + for (path, contents_hash) in items { + path.hash(&mut hasher); + contents_hash.hash(&mut hasher); + } + format!("{:x}", hasher.finish()) +} + +fn collect_fingerprint_items(dir: &Dir<'_>, items: &mut Vec<(String, Option)>) { + for entry in dir.entries() { + match entry { + include_dir::DirEntry::Dir(subdir) => { + items.push((subdir.path().to_string_lossy().to_string(), None)); + collect_fingerprint_items(subdir, items); + } + include_dir::DirEntry::File(file) => { + let mut file_hasher = DefaultHasher::new(); + file.contents().hash(&mut file_hasher); + items.push(( + file.path().to_string_lossy().to_string(), + Some(file_hasher.finish()), + )); + } + } + } +} + +/// Writes the embedded `include_dir::Dir` to disk under `dest`. +/// +/// Preserves the embedded directory structure. +fn write_embedded_dir(dir: &Dir<'_>, dest: &AbsolutePathBuf) -> Result<(), SystemSkillsError> { + fs::create_dir_all(dest.as_path()) + .map_err(|source| SystemSkillsError::io("create system skills dir", source))?; + + for entry in dir.entries() { + match entry { + include_dir::DirEntry::Dir(subdir) => { + let subdir_dest = dest.join(subdir.path()).map_err(|source| { + SystemSkillsError::io("resolve system skills subdir", source) + })?; + fs::create_dir_all(subdir_dest.as_path()).map_err(|source| { + SystemSkillsError::io("create system skills subdir", source) + })?; + write_embedded_dir(subdir, dest)?; + } + include_dir::DirEntry::File(file) => { + let path = dest.join(file.path()).map_err(|source| { + SystemSkillsError::io("resolve system skills file", source) + })?; + if let Some(parent) = path.as_path().parent() { + fs::create_dir_all(parent).map_err(|source| { + SystemSkillsError::io("create system skills file parent", source) + })?; + } + fs::write(path.as_path(), file.contents()) + .map_err(|source| SystemSkillsError::io("write system skill file", source))?; + } + } + } + + Ok(()) +} + +#[derive(Debug, Error)] +pub enum SystemSkillsError { + #[error("io error while {action}: {source}")] + Io { + action: &'static str, + #[source] + source: std::io::Error, + }, +} + +impl SystemSkillsError { + fn io(action: &'static str, source: std::io::Error) -> Self { + Self::Io { action, source } + } +} + +#[cfg(test)] +mod tests { + use super::SYSTEM_SKILLS_DIR; + use super::collect_fingerprint_items; + + #[test] + fn fingerprint_traverses_nested_entries() { + let mut items = Vec::new(); + collect_fingerprint_items(&SYSTEM_SKILLS_DIR, &mut items); + let mut paths: Vec = items.into_iter().map(|(path, _)| path).collect(); + paths.sort_unstable(); + + assert!( + paths + .binary_search_by(|probe| probe.as_str().cmp("skill-creator/SKILL.md")) + .is_ok() + ); + assert!( + paths + .binary_search_by(|probe| probe.as_str().cmp("skill-creator/scripts/init_skill.py")) + .is_ok() + ); + } +} From f5d7a7456854454a289e5f1179f6e35b2481c622 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 21 Feb 2026 00:44:55 -0800 Subject: [PATCH 035/282] chore: delete empty codex-rs/code file (#12440) This file was added in https://github.com/openai/codex/pull/4195, but I think it may have been a mistake? --- codex-rs/code | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 codex-rs/code diff --git a/codex-rs/code b/codex-rs/code deleted file mode 100644 index e69de29bb2..0000000000 From e7b6f38b58932e1f0d3512e5e69e4ad58d9022f1 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 21 Feb 2026 00:50:27 -0800 Subject: [PATCH 036/282] Delete AggregatedStream (#12441) Used only in test --- codex-rs/codex-api/src/endpoint/aggregate.rs | 163 ------------------- codex-rs/codex-api/src/endpoint/mod.rs | 1 - codex-rs/codex-api/src/lib.rs | 1 - codex-rs/codex-api/tests/sse_end_to_end.rs | 68 -------- 4 files changed, 233 deletions(-) delete mode 100644 codex-rs/codex-api/src/endpoint/aggregate.rs diff --git a/codex-rs/codex-api/src/endpoint/aggregate.rs b/codex-rs/codex-api/src/endpoint/aggregate.rs deleted file mode 100644 index 6d8e785c82..0000000000 --- a/codex-rs/codex-api/src/endpoint/aggregate.rs +++ /dev/null @@ -1,163 +0,0 @@ -use crate::common::ResponseEvent; -use crate::common::ResponseStream; -use crate::error::ApiError; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ReasoningItemContent; -use codex_protocol::models::ResponseItem; -use futures::Stream; -use std::collections::VecDeque; -use std::pin::Pin; -use std::task::Context; -use std::task::Poll; - -/// Stream adapter that merges token deltas into a single assistant message per turn. -pub struct AggregatedStream { - inner: ResponseStream, - cumulative: String, - cumulative_reasoning: String, - pending: VecDeque, -} - -impl Stream for AggregatedStream { - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - - if let Some(ev) = this.pending.pop_front() { - return Poll::Ready(Some(Ok(ev))); - } - - loop { - match Pin::new(&mut this.inner).poll_next(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(None) => return Poll::Ready(None), - Poll::Ready(Some(Err(err))) => return Poll::Ready(Some(Err(err))), - Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item)))) => { - let is_assistant_message = matches!( - &item, - ResponseItem::Message { role, .. } if role == "assistant" - ); - - if is_assistant_message { - if this.cumulative.is_empty() - && let ResponseItem::Message { content, .. } = &item - && let Some(text) = content.iter().find_map(|c| match c { - ContentItem::OutputText { text } => Some(text), - _ => None, - }) - { - this.cumulative.push_str(text); - } - continue; - } - - return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item)))); - } - Poll::Ready(Some(Ok(ResponseEvent::ServerReasoningIncluded(included)))) => { - return Poll::Ready(Some(Ok(ResponseEvent::ServerReasoningIncluded(included)))); - } - Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))) => { - return Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))); - } - Poll::Ready(Some(Ok(ResponseEvent::ModelsEtag(etag)))) => { - return Poll::Ready(Some(Ok(ResponseEvent::ModelsEtag(etag)))); - } - Poll::Ready(Some(Ok(ResponseEvent::ServerModel(model)))) => { - return Poll::Ready(Some(Ok(ResponseEvent::ServerModel(model)))); - } - Poll::Ready(Some(Ok(ResponseEvent::Completed { - response_id, - token_usage, - can_append: _can_append, - }))) => { - let mut emitted_any = false; - - if !this.cumulative_reasoning.is_empty() { - let aggregated_reasoning = ResponseItem::Reasoning { - id: String::new(), - summary: Vec::new(), - content: Some(vec![ReasoningItemContent::ReasoningText { - text: std::mem::take(&mut this.cumulative_reasoning), - }]), - encrypted_content: None, - }; - this.pending - .push_back(ResponseEvent::OutputItemDone(aggregated_reasoning)); - emitted_any = true; - } - - if !this.cumulative.is_empty() { - let aggregated_message = ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: std::mem::take(&mut this.cumulative), - }], - end_turn: None, - phase: None, - }; - this.pending - .push_back(ResponseEvent::OutputItemDone(aggregated_message)); - emitted_any = true; - } - - if emitted_any { - this.pending.push_back(ResponseEvent::Completed { - response_id: response_id.clone(), - token_usage: token_usage.clone(), - can_append: false, - }); - if let Some(ev) = this.pending.pop_front() { - return Poll::Ready(Some(Ok(ev))); - } - } - - return Poll::Ready(Some(Ok(ResponseEvent::Completed { - response_id, - token_usage, - can_append: false, - }))); - } - Poll::Ready(Some(Ok(ResponseEvent::Created))) => continue, - Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta)))) => { - this.cumulative.push_str(&delta); - continue; - } - Poll::Ready(Some(Ok(ResponseEvent::ReasoningContentDelta { - delta, - content_index: _, - }))) => { - this.cumulative_reasoning.push_str(&delta); - continue; - } - Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta { .. }))) => continue, - Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded { .. }))) => continue, - Poll::Ready(Some(Ok(ResponseEvent::OutputItemAdded(item)))) => { - return Poll::Ready(Some(Ok(ResponseEvent::OutputItemAdded(item)))); - } - } - } - } -} - -pub trait AggregateStreamExt { - fn aggregate(self) -> AggregatedStream; -} - -impl AggregateStreamExt for ResponseStream { - fn aggregate(self) -> AggregatedStream { - AggregatedStream::new(self) - } -} - -impl AggregatedStream { - fn new(inner: ResponseStream) -> Self { - AggregatedStream { - inner, - cumulative: String::new(), - cumulative_reasoning: String::new(), - pending: VecDeque::new(), - } - } -} diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index 981643904e..6a748e533d 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -1,4 +1,3 @@ -pub mod aggregate; pub mod compact; pub mod memories; pub mod models; diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 23c08d7e2b..b9152ec576 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -25,7 +25,6 @@ pub use crate::common::ResponseEvent; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; pub use crate::common::create_text_param_for_request; -pub use crate::endpoint::aggregate::AggregateStreamExt; pub use crate::endpoint::compact::CompactClient; pub use crate::endpoint::memories::MemoriesClient; pub use crate::endpoint::models::ModelsClient; diff --git a/codex-rs/codex-api/tests/sse_end_to_end.rs b/codex-rs/codex-api/tests/sse_end_to_end.rs index 9f29033783..ca840d1b9f 100644 --- a/codex-rs/codex-api/tests/sse_end_to_end.rs +++ b/codex-rs/codex-api/tests/sse_end_to_end.rs @@ -3,7 +3,6 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; use bytes::Bytes; -use codex_api::AggregateStreamExt; use codex_api::AuthProvider; use codex_api::Provider; use codex_api::ResponseEvent; @@ -14,7 +13,6 @@ use codex_client::Request; use codex_client::Response; use codex_client::StreamResponse; use codex_client::TransportError; -use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use futures::StreamExt; use http::HeaderMap; @@ -172,69 +170,3 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> Ok(()) } - -#[tokio::test] -async fn responses_stream_aggregates_output_text_deltas() -> Result<()> { - let delta1 = serde_json::json!({ - "type": "response.output_text.delta", - "delta": "Hello, " - }); - - let delta2 = serde_json::json!({ - "type": "response.output_text.delta", - "delta": "world" - }); - - let completed = serde_json::json!({ - "type": "response.completed", - "response": { "id": "resp-agg" } - }); - - let body = build_responses_body(vec![delta1, delta2, completed]); - let transport = FixtureSseTransport::new(body); - let client = ResponsesClient::new(transport, provider("openai"), NoAuth); - - let stream = client - .stream( - serde_json::json!({"echo": true}), - HeaderMap::new(), - Compression::None, - None, - ) - .await?; - - let mut stream = stream.aggregate(); - let mut events = Vec::new(); - while let Some(ev) = stream.next().await { - events.push(ev?); - } - - let events: Vec = events - .into_iter() - .filter(|ev| !matches!(ev, ResponseEvent::RateLimits(_))) - .collect(); - - assert_eq!(events.len(), 2); - - match &events[0] { - ResponseEvent::OutputItemDone(ResponseItem::Message { content, .. }) => { - let mut aggregated = String::new(); - for item in content { - if let ContentItem::OutputText { text } = item { - aggregated.push_str(text); - } - } - assert_eq!(aggregated, "Hello, world"); - } - other => panic!("unexpected first event: {other:?}"), - } - - match &events[1] { - ResponseEvent::Completed { response_id, .. } => { - assert_eq!(response_id, "resp-agg"); - } - other => panic!("unexpected second event: {other:?}"), - } - - Ok(()) -} From b3202cbd581721774b73d2d9c90c56cc43974425 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Sat, 21 Feb 2026 10:16:34 -0800 Subject: [PATCH 037/282] feat(linux-sandbox): implement proxy-only egress via TCP-UDS-TCP bridge (#11293) ## Summary - Implement Linux proxy-only routing in `codex-rs/linux-sandbox` with a two-stage bridge: host namespace `loopback TCP proxy endpoint -> UDS`, then bwrap netns `loopback TCP listener -> host UDS`. - Add hidden `--proxy-route-spec` plumbing for outer-to-inner stage handoff. - Fail closed in proxy mode when no valid loopback proxy endpoints can be routed. - Introduce explicit network seccomp modes: `Restricted` (legacy restricted networking) and `ProxyRouted` (allow INET/INET6 for routed proxy access, deny `AF_UNIX` and `socketpair`). - Enforce that proxy bridge/routing is bwrap-only by validating `--apply-seccomp-then-exec` requires `--use-bwrap-sandbox`. - Keep landlock-only flows unchanged (no proxy bridge behavior outside bwrap). --------- Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 2 + codex-rs/linux-sandbox/Cargo.toml | 2 + codex-rs/linux-sandbox/README.md | 5 + codex-rs/linux-sandbox/src/bwrap.rs | 34 +- codex-rs/linux-sandbox/src/landlock.rs | 168 +++- codex-rs/linux-sandbox/src/lib.rs | 2 + codex-rs/linux-sandbox/src/linux_run_main.rs | 168 ++-- .../linux-sandbox/src/linux_run_main_tests.rs | 159 ++++ codex-rs/linux-sandbox/src/proxy_routing.rs | 796 ++++++++++++++++++ .../tests/suite/managed_proxy.rs | 313 +++++++ codex-rs/linux-sandbox/tests/suite/mod.rs | 1 + 11 files changed, 1501 insertions(+), 149 deletions(-) create mode 100644 codex-rs/linux-sandbox/src/linux_run_main_tests.rs create mode 100644 codex-rs/linux-sandbox/src/proxy_routing.rs create mode 100644 codex-rs/linux-sandbox/tests/suite/managed_proxy.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 03276a7adb..31bc8d65a7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1921,9 +1921,11 @@ dependencies = [ "pkg-config", "pretty_assertions", "seccompiler", + "serde", "serde_json", "tempfile", "tokio", + "url", ] [[package]] diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 63af5c81fa..88da2d7479 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -23,7 +23,9 @@ codex-utils-absolute-path = { workspace = true } landlock = { workspace = true } libc = { workspace = true } seccompiler = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +url = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 0abd641708..b03919c963 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -28,6 +28,11 @@ into this binary. - When enabled, the helper isolates the PID namespace via `--unshare-pid`. - When enabled and network is restricted without proxy routing, the helper also isolates the network namespace via `--unshare-net`. +- In managed proxy mode, the helper uses `--unshare-net` plus an internal + TCP->UDS->TCP routing bridge so tool traffic reaches only configured proxy + endpoints. +- In managed proxy mode, after the bridge is live, seccomp blocks new + AF_UNIX/socketpair creation for the user command. - When enabled, it mounts a fresh `/proc` via `--proc /proc` by default, but you can skip this in restrictive container environments with `--no-proc`. diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 854774b781..d1fefc1747 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -49,8 +49,8 @@ pub(crate) enum BwrapNetworkMode { Isolated, /// Intended proxy-only mode. /// - /// Bubblewrap does not currently enforce proxy-only egress, so this is - /// treated as isolated for fail-closed behavior. + /// Bubblewrap enforces this by unsharing the network namespace. The + /// proxy-routing bridge is established by the helper process after startup. ProxyOnly, } @@ -323,6 +323,7 @@ fn find_first_non_existent_component(target_path: &Path) -> Option { mod tests { use super::*; use codex_protocol::protocol::SandboxPolicy; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; #[test] @@ -373,4 +374,33 @@ mod tests { ] ); } + + #[test] + fn mounts_dev_before_writable_dev_binds() { + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![AbsolutePathBuf::try_from(Path::new("/dev")).expect("/dev path")], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + let args = create_filesystem_args(&sandbox_policy, Path::new("/")).expect("bwrap fs args"); + assert_eq!( + args, + vec![ + "--ro-bind".to_string(), + "/".to_string(), + "/".to_string(), + "--dev".to_string(), + "/dev".to_string(), + "--bind".to_string(), + "/dev".to_string(), + "/dev".to_string(), + "--bind".to_string(), + "/".to_string(), + "/".to_string(), + ] + ); + } } diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 621bfd5b60..f9477a1264 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -43,22 +43,26 @@ pub(crate) fn apply_sandbox_policy_to_current_thread( cwd: &Path, apply_landlock_fs: bool, allow_network_for_proxy: bool, + proxy_routed_network: bool, ) -> Result<()> { - let install_network_seccomp = - should_install_network_seccomp(sandbox_policy, allow_network_for_proxy); + let network_seccomp_mode = network_seccomp_mode( + sandbox_policy, + allow_network_for_proxy, + proxy_routed_network, + ); // `PR_SET_NO_NEW_PRIVS` is required for seccomp, but it also prevents // setuid privilege elevation. Many `bwrap` deployments rely on setuid, so // we avoid this unless we need seccomp or we are explicitly using the // legacy Landlock filesystem pipeline. - if install_network_seccomp + if network_seccomp_mode.is_some() || (apply_landlock_fs && !sandbox_policy.has_full_disk_write_access()) { set_no_new_privs()?; } - if install_network_seccomp { - install_network_seccomp_filter_on_current_thread()?; + if let Some(mode) = network_seccomp_mode { + install_network_seccomp_filter_on_current_thread(mode)?; } if apply_landlock_fs && !sandbox_policy.has_full_disk_write_access() { @@ -80,6 +84,12 @@ pub(crate) fn apply_sandbox_policy_to_current_thread( Ok(()) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NetworkSeccompMode { + Restricted, + ProxyRouted, +} + fn should_install_network_seccomp( sandbox_policy: &SandboxPolicy, allow_network_for_proxy: bool, @@ -89,6 +99,20 @@ fn should_install_network_seccomp( !sandbox_policy.has_full_network_access() || allow_network_for_proxy } +fn network_seccomp_mode( + sandbox_policy: &SandboxPolicy, + allow_network_for_proxy: bool, + proxy_routed_network: bool, +) -> Option { + if !should_install_network_seccomp(sandbox_policy, allow_network_for_proxy) { + None + } else if proxy_routed_network { + Some(NetworkSeccompMode::ProxyRouted) + } else { + Some(NetworkSeccompMode::Restricted) + } +} + /// Enable `PR_SET_NO_NEW_PRIVS` so seccomp can be applied safely. fn set_no_new_privs() -> Result<()> { let result = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; @@ -135,51 +159,87 @@ fn install_filesystem_landlock_rules_on_current_thread( Ok(()) } -/// Installs a seccomp filter that blocks outbound network access except for -/// AF_UNIX domain sockets. +/// Installs a seccomp filter for Linux network sandboxing. /// /// The filter is applied to the current thread so only the sandboxed child /// inherits it. -fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> { +fn install_network_seccomp_filter_on_current_thread( + mode: NetworkSeccompMode, +) -> std::result::Result<(), SandboxErr> { + fn deny_syscall(rules: &mut BTreeMap>, nr: i64) { + rules.insert(nr, vec![]); // empty rule vec = unconditional match + } + // Build rule map. let mut rules: BTreeMap> = BTreeMap::new(); - // Helper – insert unconditional deny rule for syscall number. - let mut deny_syscall = |nr: i64| { - rules.insert(nr, vec![]); // empty rule vec = unconditional match - }; + deny_syscall(&mut rules, libc::SYS_ptrace); + deny_syscall(&mut rules, libc::SYS_io_uring_setup); + deny_syscall(&mut rules, libc::SYS_io_uring_enter); + deny_syscall(&mut rules, libc::SYS_io_uring_register); - deny_syscall(libc::SYS_connect); - deny_syscall(libc::SYS_accept); - deny_syscall(libc::SYS_accept4); - deny_syscall(libc::SYS_bind); - deny_syscall(libc::SYS_listen); - deny_syscall(libc::SYS_getpeername); - deny_syscall(libc::SYS_getsockname); - deny_syscall(libc::SYS_shutdown); - deny_syscall(libc::SYS_sendto); - deny_syscall(libc::SYS_sendmmsg); - // NOTE: allowing recvfrom allows some tools like: `cargo clippy` to run - // with their socketpair + child processes for sub-proc management - // deny_syscall(libc::SYS_recvfrom); - deny_syscall(libc::SYS_recvmmsg); - deny_syscall(libc::SYS_getsockopt); - deny_syscall(libc::SYS_setsockopt); - deny_syscall(libc::SYS_ptrace); - deny_syscall(libc::SYS_io_uring_setup); - deny_syscall(libc::SYS_io_uring_enter); - deny_syscall(libc::SYS_io_uring_register); + match mode { + NetworkSeccompMode::Restricted => { + deny_syscall(&mut rules, libc::SYS_connect); + deny_syscall(&mut rules, libc::SYS_accept); + deny_syscall(&mut rules, libc::SYS_accept4); + deny_syscall(&mut rules, libc::SYS_bind); + deny_syscall(&mut rules, libc::SYS_listen); + deny_syscall(&mut rules, libc::SYS_getpeername); + deny_syscall(&mut rules, libc::SYS_getsockname); + deny_syscall(&mut rules, libc::SYS_shutdown); + deny_syscall(&mut rules, libc::SYS_sendto); + deny_syscall(&mut rules, libc::SYS_sendmmsg); + // NOTE: allowing recvfrom allows some tools like: `cargo clippy` + // to run with their socketpair + child processes for sub-proc + // management. + // deny_syscall(&mut rules, libc::SYS_recvfrom); + deny_syscall(&mut rules, libc::SYS_recvmmsg); + deny_syscall(&mut rules, libc::SYS_getsockopt); + deny_syscall(&mut rules, libc::SYS_setsockopt); - // For `socket` we allow AF_UNIX (arg0 == AF_UNIX) and deny everything else. - let unix_only_rule = SeccompRule::new(vec![SeccompCondition::new( - 0, // first argument (domain) - SeccompCmpArgLen::Dword, - SeccompCmpOp::Ne, - libc::AF_UNIX as u64, - )?])?; + // For `socket` we allow AF_UNIX (arg0 == AF_UNIX) and deny + // everything else. + let unix_only_rule = SeccompRule::new(vec![SeccompCondition::new( + 0, // first argument (domain) + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_UNIX as u64, + )?])?; - rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]); - rules.insert(libc::SYS_socketpair, vec![unix_only_rule]); // always deny (Unix can use socketpair but fine, keep open?) + rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]); + rules.insert(libc::SYS_socketpair, vec![unix_only_rule]); + } + NetworkSeccompMode::ProxyRouted => { + // In proxy-routed mode we allow IP sockets in the isolated + // namespace (used to reach the local TCP bridge) but deny all + // other socket families, including AF_UNIX. This prevents + // bypassing the routed bridge via new Unix sockets and narrows the + // socket surface in proxy-only mode. + let deny_non_ip_socket = SeccompRule::new(vec![ + SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_INET as u64, + )?, + SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_INET6 as u64, + )?, + ])?; + let deny_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Eq, + libc::AF_UNIX as u64, + )?])?; + rules.insert(libc::SYS_socket, vec![deny_non_ip_socket]); + rules.insert(libc::SYS_socketpair, vec![deny_unix_socketpair]); + } + } let filter = SeccompFilter::new( rules, @@ -203,6 +263,8 @@ fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), #[cfg(test)] mod tests { + use super::NetworkSeccompMode; + use super::network_seccomp_mode; use super::should_install_network_seccomp; use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; @@ -234,4 +296,28 @@ mod tests { true )); } + + #[test] + fn managed_proxy_routes_use_proxy_routed_seccomp_mode() { + assert_eq!( + network_seccomp_mode(&SandboxPolicy::DangerFullAccess, true, true), + Some(NetworkSeccompMode::ProxyRouted) + ); + } + + #[test] + fn restricted_network_without_proxy_routing_uses_restricted_mode() { + assert_eq!( + network_seccomp_mode(&SandboxPolicy::new_read_only_policy(), false, false), + Some(NetworkSeccompMode::Restricted) + ); + } + + #[test] + fn full_network_without_managed_proxy_skips_network_seccomp_mode() { + assert_eq!( + network_seccomp_mode(&SandboxPolicy::DangerFullAccess, false, false), + None + ); + } } diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index 3347f3f92d..e364c19251 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -10,6 +10,8 @@ mod landlock; #[cfg(target_os = "linux")] mod linux_run_main; #[cfg(target_os = "linux")] +mod proxy_routing; +#[cfg(target_os = "linux")] mod vendored_bwrap; #[cfg(target_os = "linux")] diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 84e7cb34fc..c9fdd06a32 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -10,6 +10,8 @@ use crate::bwrap::BwrapNetworkMode; use crate::bwrap::BwrapOptions; use crate::bwrap::create_bwrap_command_args; use crate::landlock::apply_sandbox_policy_to_current_thread; +use crate::proxy_routing::activate_proxy_routes_in_netns; +use crate::proxy_routing::prepare_host_proxy_route_spec; use crate::vendored_bwrap::exec_vendored_bwrap; use crate::vendored_bwrap::run_vendored_bwrap_main; @@ -44,11 +46,15 @@ pub struct LandlockCommand { /// Internal compatibility flag. /// /// By default, restricted-network sandboxing uses isolated networking. - /// If set, sandbox setup switches to proxy-only network mode - /// (currently enforced the same as isolated networking). + /// If set, sandbox setup switches to proxy-only network mode with + /// managed routing bridges. #[arg(long = "allow-network-for-proxy", hide = true, default_value_t = false)] pub allow_network_for_proxy: bool, + /// Internal route spec used for managed proxy routing in bwrap mode. + #[arg(long = "proxy-route-spec", hide = true)] + pub proxy_route_spec: Option, + /// When set, skip mounting a fresh `/proc` even though PID isolation is /// still enabled. This is primarily intended for restrictive container /// environments that deny `--proc /proc`. @@ -74,6 +80,7 @@ pub fn run_main() -> ! { use_bwrap_sandbox, apply_seccomp_then_exec, allow_network_for_proxy, + proxy_route_spec, no_proc, command, } = LandlockCommand::parse(); @@ -81,15 +88,26 @@ pub fn run_main() -> ! { if command.is_empty() { panic!("No command specified to execute."); } + ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_bwrap_sandbox); // Inner stage: apply seccomp/no_new_privs after bubblewrap has already // established the filesystem view. if apply_seccomp_then_exec { + if allow_network_for_proxy { + let spec = proxy_route_spec + .as_deref() + .unwrap_or_else(|| panic!("managed proxy mode requires --proxy-route-spec")); + if let Err(err) = activate_proxy_routes_in_netns(spec) { + panic!("error activating Linux proxy routing bridge: {err}"); + } + } + let proxy_routing_active = allow_network_for_proxy; if let Err(e) = apply_sandbox_policy_to_current_thread( &sandbox_policy, &sandbox_policy_cwd, false, allow_network_for_proxy, + proxy_routing_active, ) { panic!("error applying Linux sandbox restrictions: {e:?}"); } @@ -102,6 +120,7 @@ pub fn run_main() -> ! { &sandbox_policy_cwd, false, allow_network_for_proxy, + false, ) { panic!("error applying Linux sandbox restrictions: {e:?}"); } @@ -112,11 +131,20 @@ pub fn run_main() -> ! { // Outer stage: bubblewrap first, then re-enter this binary in the // sandboxed environment to apply seccomp. This path never falls back // to legacy Landlock on failure. + let proxy_route_spec = + if allow_network_for_proxy { + Some(prepare_host_proxy_route_spec().unwrap_or_else(|err| { + panic!("failed to prepare host proxy routing bridge: {err}") + })) + } else { + None + }; let inner = build_inner_seccomp_command( &sandbox_policy_cwd, &sandbox_policy, use_bwrap_sandbox, allow_network_for_proxy, + proxy_route_spec, command, ); run_bwrap_with_proc_fallback( @@ -134,12 +162,19 @@ pub fn run_main() -> ! { &sandbox_policy_cwd, true, allow_network_for_proxy, + false, ) { panic!("error applying legacy Linux sandbox restrictions: {e:?}"); } exec_or_panic(command); } +fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_sandbox: bool) { + if apply_seccomp_then_exec && !use_bwrap_sandbox { + panic!("--apply-seccomp-then-exec requires --use-bwrap-sandbox"); + } +} + fn run_bwrap_with_proc_fallback( sandbox_policy_cwd: &Path, sandbox_policy: &codex_protocol::protocol::SandboxPolicy, @@ -147,14 +182,15 @@ fn run_bwrap_with_proc_fallback( mount_proc: bool, allow_network_for_proxy: bool, ) -> ! { + let network_mode = bwrap_network_mode(sandbox_policy, allow_network_for_proxy); let mut mount_proc = mount_proc; - if mount_proc && !preflight_proc_mount_support(sandbox_policy_cwd, sandbox_policy) { + if mount_proc && !preflight_proc_mount_support(sandbox_policy_cwd, sandbox_policy, network_mode) + { eprintln!("codex-linux-sandbox: bwrap could not mount /proc; retrying with --no-proc"); mount_proc = false; } - let network_mode = bwrap_network_mode(sandbox_policy, allow_network_for_proxy); let options = BwrapOptions { mount_proc, network_mode, @@ -202,19 +238,29 @@ fn build_bwrap_argv( fn preflight_proc_mount_support( sandbox_policy_cwd: &Path, sandbox_policy: &codex_protocol::protocol::SandboxPolicy, + network_mode: BwrapNetworkMode, ) -> bool { + let preflight_argv = + build_preflight_bwrap_argv(sandbox_policy_cwd, sandbox_policy, network_mode); + let stderr = run_bwrap_in_child_capture_stderr(preflight_argv); + !is_proc_mount_failure(stderr.as_str()) +} + +fn build_preflight_bwrap_argv( + sandbox_policy_cwd: &Path, + sandbox_policy: &codex_protocol::protocol::SandboxPolicy, + network_mode: BwrapNetworkMode, +) -> Vec { let preflight_command = vec![resolve_true_command()]; - let preflight_argv = build_bwrap_argv( + build_bwrap_argv( preflight_command, sandbox_policy, sandbox_policy_cwd, BwrapOptions { mount_proc: true, - network_mode: BwrapNetworkMode::FullAccess, + network_mode, }, - ); - let stderr = run_bwrap_in_child_capture_stderr(preflight_argv); - !is_proc_mount_failure(stderr.as_str()) + ) } fn resolve_true_command() -> String { @@ -318,6 +364,7 @@ fn build_inner_seccomp_command( sandbox_policy: &codex_protocol::protocol::SandboxPolicy, use_bwrap_sandbox: bool, allow_network_for_proxy: bool, + proxy_route_spec: Option, command: Vec, ) -> Vec { let current_exe = match std::env::current_exe() { @@ -342,6 +389,10 @@ fn build_inner_seccomp_command( } if allow_network_for_proxy { inner.push("--allow-network-for-proxy".to_string()); + let proxy_route_spec = proxy_route_spec + .unwrap_or_else(|| panic!("managed proxy mode requires a proxy route spec")); + inner.push("--proxy-route-spec".to_string()); + inner.push(proxy_route_spec); } inner.push("--".to_string()); inner.extend(command); @@ -371,100 +422,5 @@ fn exec_or_panic(command: Vec) -> ! { panic!("Failed to execvp {}: {err}", command[0].as_str()); } -#[cfg(test)] -mod tests { - use super::*; - use codex_protocol::protocol::SandboxPolicy; - use pretty_assertions::assert_eq; - - #[test] - fn detects_proc_mount_invalid_argument_failure() { - let stderr = "bwrap: Can't mount proc on /newroot/proc: Invalid argument"; - assert_eq!(is_proc_mount_failure(stderr), true); - } - - #[test] - fn detects_proc_mount_operation_not_permitted_failure() { - let stderr = "bwrap: Can't mount proc on /newroot/proc: Operation not permitted"; - assert_eq!(is_proc_mount_failure(stderr), true); - } - - #[test] - fn detects_proc_mount_permission_denied_failure() { - let stderr = "bwrap: Can't mount proc on /newroot/proc: Permission denied"; - assert_eq!(is_proc_mount_failure(stderr), true); - } - - #[test] - fn ignores_non_proc_mount_errors() { - let stderr = "bwrap: Can't bind mount /dev/null: Operation not permitted"; - assert_eq!(is_proc_mount_failure(stderr), false); - } - - #[test] - fn inserts_bwrap_argv0_before_command_separator() { - let argv = build_bwrap_argv( - vec!["/bin/true".to_string()], - &SandboxPolicy::new_read_only_policy(), - Path::new("/"), - BwrapOptions { - mount_proc: true, - network_mode: BwrapNetworkMode::FullAccess, - }, - ); - assert_eq!( - argv, - vec![ - "bwrap".to_string(), - "--new-session".to_string(), - "--die-with-parent".to_string(), - "--ro-bind".to_string(), - "/".to_string(), - "/".to_string(), - "--dev".to_string(), - "/dev".to_string(), - "--unshare-pid".to_string(), - "--proc".to_string(), - "/proc".to_string(), - "--argv0".to_string(), - "codex-linux-sandbox".to_string(), - "--".to_string(), - "/bin/true".to_string(), - ] - ); - } - - #[test] - fn inserts_unshare_net_when_network_isolation_requested() { - let argv = build_bwrap_argv( - vec!["/bin/true".to_string()], - &SandboxPolicy::new_read_only_policy(), - Path::new("/"), - BwrapOptions { - mount_proc: true, - network_mode: BwrapNetworkMode::Isolated, - }, - ); - assert_eq!(argv.contains(&"--unshare-net".to_string()), true); - } - - #[test] - fn inserts_unshare_net_when_proxy_only_network_mode_requested() { - let argv = build_bwrap_argv( - vec!["/bin/true".to_string()], - &SandboxPolicy::new_read_only_policy(), - Path::new("/"), - BwrapOptions { - mount_proc: true, - network_mode: BwrapNetworkMode::ProxyOnly, - }, - ); - assert_eq!(argv.contains(&"--unshare-net".to_string()), true); - } - - #[test] - fn proxy_only_mode_takes_precedence_over_full_network_policy() { - let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true); - assert_eq!(mode, BwrapNetworkMode::ProxyOnly); - } -} +#[path = "linux_run_main_tests.rs"] +mod tests; diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs new file mode 100644 index 0000000000..11471afbe7 --- /dev/null +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -0,0 +1,159 @@ +#[cfg(test)] +use super::*; +#[cfg(test)] +use codex_protocol::protocol::SandboxPolicy; + +#[test] +fn detects_proc_mount_invalid_argument_failure() { + let stderr = "bwrap: Can't mount proc on /newroot/proc: Invalid argument"; + assert!(is_proc_mount_failure(stderr)); +} + +#[test] +fn detects_proc_mount_operation_not_permitted_failure() { + let stderr = "bwrap: Can't mount proc on /newroot/proc: Operation not permitted"; + assert!(is_proc_mount_failure(stderr)); +} + +#[test] +fn detects_proc_mount_permission_denied_failure() { + let stderr = "bwrap: Can't mount proc on /newroot/proc: Permission denied"; + assert!(is_proc_mount_failure(stderr)); +} + +#[test] +fn ignores_non_proc_mount_errors() { + let stderr = "bwrap: Can't bind mount /dev/null: Operation not permitted"; + assert!(!is_proc_mount_failure(stderr)); +} + +#[test] +fn inserts_bwrap_argv0_before_command_separator() { + let argv = build_bwrap_argv( + vec!["/bin/true".to_string()], + &SandboxPolicy::new_read_only_policy(), + Path::new("/"), + BwrapOptions { + mount_proc: true, + network_mode: BwrapNetworkMode::FullAccess, + }, + ); + assert_eq!( + argv, + vec![ + "bwrap".to_string(), + "--new-session".to_string(), + "--die-with-parent".to_string(), + "--ro-bind".to_string(), + "/".to_string(), + "/".to_string(), + "--dev".to_string(), + "/dev".to_string(), + "--unshare-pid".to_string(), + "--proc".to_string(), + "/proc".to_string(), + "--argv0".to_string(), + "codex-linux-sandbox".to_string(), + "--".to_string(), + "/bin/true".to_string(), + ] + ); +} + +#[test] +fn inserts_unshare_net_when_network_isolation_requested() { + let argv = build_bwrap_argv( + vec!["/bin/true".to_string()], + &SandboxPolicy::new_read_only_policy(), + Path::new("/"), + BwrapOptions { + mount_proc: true, + network_mode: BwrapNetworkMode::Isolated, + }, + ); + assert!(argv.contains(&"--unshare-net".to_string())); +} + +#[test] +fn inserts_unshare_net_when_proxy_only_network_mode_requested() { + let argv = build_bwrap_argv( + vec!["/bin/true".to_string()], + &SandboxPolicy::new_read_only_policy(), + Path::new("/"), + BwrapOptions { + mount_proc: true, + network_mode: BwrapNetworkMode::ProxyOnly, + }, + ); + assert!(argv.contains(&"--unshare-net".to_string())); +} + +#[test] +fn proxy_only_mode_takes_precedence_over_full_network_policy() { + let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true); + assert_eq!(mode, BwrapNetworkMode::ProxyOnly); +} + +#[test] +fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() { + let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true); + let argv = build_preflight_bwrap_argv(Path::new("/"), &SandboxPolicy::DangerFullAccess, mode); + assert!(argv.iter().any(|arg| arg == "--")); +} + +#[test] +fn managed_proxy_inner_command_includes_route_spec() { + let args = build_inner_seccomp_command( + Path::new("/tmp"), + &SandboxPolicy::new_read_only_policy(), + true, + true, + Some("{\"routes\":[]}".to_string()), + vec!["/bin/true".to_string()], + ); + + assert!(args.iter().any(|arg| arg == "--proxy-route-spec")); + assert!(args.iter().any(|arg| arg == "{\"routes\":[]}")); +} + +#[test] +fn non_managed_inner_command_omits_route_spec() { + let args = build_inner_seccomp_command( + Path::new("/tmp"), + &SandboxPolicy::new_read_only_policy(), + true, + false, + None, + vec!["/bin/true".to_string()], + ); + + assert!(!args.iter().any(|arg| arg == "--proxy-route-spec")); +} + +#[test] +fn managed_proxy_inner_command_requires_route_spec() { + let result = std::panic::catch_unwind(|| { + build_inner_seccomp_command( + Path::new("/tmp"), + &SandboxPolicy::new_read_only_policy(), + true, + true, + None, + vec!["/bin/true".to_string()], + ) + }); + assert!(result.is_err()); +} + +#[test] +fn apply_seccomp_then_exec_without_bwrap_panics() { + let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, false)); + assert!(result.is_err()); +} + +#[test] +fn valid_inner_stage_modes_do_not_panic() { + ensure_inner_stage_mode_is_valid(false, false); + ensure_inner_stage_mode_is_valid(false, true); + ensure_inner_stage_mode_is_valid(true, true); +} diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs new file mode 100644 index 0000000000..f57472c8a1 --- /dev/null +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -0,0 +1,796 @@ +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::fs::DirBuilder; +use std::fs::File; +use std::fs::Permissions; +use std::io; +use std::io::Read; +use std::io::Write; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::SocketAddr; +use std::net::TcpListener; +use std::net::TcpStream; +use std::os::fd::FromRawFd; +use std::os::unix::fs::DirBuilderExt; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::UnixListener; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use url::Url; + +const PROXY_ENV_KEYS: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "FTP_PROXY", + "YARN_HTTP_PROXY", + "YARN_HTTPS_PROXY", + "NPM_CONFIG_HTTP_PROXY", + "NPM_CONFIG_HTTPS_PROXY", + "NPM_CONFIG_PROXY", + "BUNDLE_HTTP_PROXY", + "BUNDLE_HTTPS_PROXY", + "PIP_PROXY", + "DOCKER_HTTP_PROXY", + "DOCKER_HTTPS_PROXY", +]; + +const PROXY_SOCKET_DIR_PREFIX: &str = "codex-linux-sandbox-proxy-"; +const HOST_BRIDGE_READY: u8 = 1; +const LOOPBACK_INTERFACE_NAME: &[u8] = b"lo"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct ProxyRouteSpec { + routes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct ProxyRouteEntry { + env_key: String, + uds_path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PlannedProxyRoute { + env_key: String, + endpoint: SocketAddr, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProxyRoutePlan { + routes: Vec, + has_proxy_config: bool, +} + +pub(crate) fn prepare_host_proxy_route_spec() -> io::Result { + let env: HashMap = std::env::vars().collect(); + let plan = plan_proxy_routes(&env); + + if plan.routes.is_empty() { + let message = if plan.has_proxy_config { + "managed proxy mode requires parseable loopback proxy endpoints" + } else { + "managed proxy mode requires proxy environment variables" + }; + return Err(io::Error::new(io::ErrorKind::InvalidInput, message)); + } + + let socket_parent_dir = proxy_socket_parent_dir(); + let _ = cleanup_stale_proxy_socket_dirs_in(socket_parent_dir.as_path()); + + let socket_dir = create_proxy_socket_dir()?; + let mut socket_by_endpoint: BTreeMap = BTreeMap::new(); + let mut next_index = 0usize; + for route in &plan.routes { + if socket_by_endpoint.contains_key(&route.endpoint) { + continue; + } + let socket_path = socket_dir.join(format!("proxy-route-{next_index}.sock")); + next_index += 1; + socket_by_endpoint.insert(route.endpoint, socket_path); + } + + let mut host_bridge_pids = Vec::with_capacity(socket_by_endpoint.len()); + for (endpoint, socket_path) in &socket_by_endpoint { + host_bridge_pids.push(spawn_host_bridge(*endpoint, socket_path)?); + } + spawn_proxy_socket_dir_cleanup_worker(socket_dir, host_bridge_pids)?; + + let mut routes = Vec::with_capacity(plan.routes.len()); + for route in plan.routes { + let Some(uds_path) = socket_by_endpoint.get(&route.endpoint) else { + return Err(io::Error::other(format!( + "missing UDS path for endpoint {}", + route.endpoint + ))); + }; + routes.push(ProxyRouteEntry { + env_key: route.env_key, + uds_path: uds_path.clone(), + }); + } + + serde_json::to_string(&ProxyRouteSpec { routes }).map_err(io::Error::other) +} + +pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Result<()> { + let spec: ProxyRouteSpec = serde_json::from_str(serialized_spec).map_err(io::Error::other)?; + + if spec.routes.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "proxy routing spec contained no routes", + )); + } + + let mut local_port_by_uds_path: BTreeMap = BTreeMap::new(); + for route in &spec.routes { + if local_port_by_uds_path.contains_key(&route.uds_path) { + continue; + } + let local_port = spawn_local_bridge(route.uds_path.as_path())?; + local_port_by_uds_path.insert(route.uds_path.clone(), local_port); + } + + for route in spec.routes { + let Some(local_port) = local_port_by_uds_path.get(&route.uds_path) else { + return Err(io::Error::other(format!( + "missing local bridge port for UDS path {}", + route.uds_path.display() + ))); + }; + let original_value = std::env::var(&route.env_key).map_err(|_| { + io::Error::new( + io::ErrorKind::NotFound, + format!("missing proxy env key {}", route.env_key), + ) + })?; + let Some(rewritten) = rewrite_proxy_env_value(&original_value, *local_port) else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("could not rewrite proxy URL for env key {}", route.env_key), + )); + }; + // SAFETY: this helper process is single-threaded at this point, and + // env mutation happens before execing the user command. + unsafe { + std::env::set_var(route.env_key, rewritten); + } + } + + Ok(()) +} + +fn plan_proxy_routes(env: &HashMap) -> ProxyRoutePlan { + let mut routes = Vec::new(); + let mut has_proxy_config = false; + + for (key, value) in env { + if !is_proxy_env_key(key) { + continue; + } + + let trimmed = value.trim(); + if trimmed.is_empty() { + continue; + } + has_proxy_config = true; + + let Some(endpoint) = parse_loopback_proxy_endpoint(trimmed) else { + continue; + }; + routes.push(PlannedProxyRoute { + env_key: key.clone(), + endpoint, + }); + } + + routes.sort_by(|left, right| left.env_key.cmp(&right.env_key)); + ProxyRoutePlan { + routes, + has_proxy_config, + } +} + +fn is_proxy_env_key(key: &str) -> bool { + let upper = key.to_ascii_uppercase(); + PROXY_ENV_KEYS.contains(&upper.as_str()) +} + +fn parse_loopback_proxy_endpoint(proxy_url: &str) -> Option { + let candidate = if proxy_url.contains("://") { + proxy_url.to_string() + } else { + format!("http://{proxy_url}") + }; + + let parsed = Url::parse(&candidate).ok()?; + let host = parsed.host_str()?; + if !is_loopback_host(host) { + return None; + } + + let scheme = parsed.scheme().to_ascii_lowercase(); + let port = parsed + .port() + .unwrap_or_else(|| default_proxy_port(scheme.as_str())); + if port == 0 { + return None; + } + + let ip = if host.eq_ignore_ascii_case("localhost") { + IpAddr::V4(Ipv4Addr::LOCALHOST) + } else { + host.parse::().ok()? + }; + if ip.is_loopback() { + Some(SocketAddr::new(ip, port)) + } else { + None + } +} + +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1" +} + +fn default_proxy_port(scheme: &str) -> u16 { + match scheme { + "https" => 443, + "socks5" | "socks5h" | "socks4" | "socks4a" => 1080, + _ => 80, + } +} + +fn rewrite_proxy_env_value(proxy_url: &str, local_port: u16) -> Option { + let had_scheme = proxy_url.contains("://"); + let candidate = if had_scheme { + proxy_url.to_string() + } else { + format!("http://{proxy_url}") + }; + + let mut parsed = Url::parse(&candidate).ok()?; + parsed.set_host(Some("127.0.0.1")).ok()?; + parsed.set_port(Some(local_port)).ok()?; + let mut rewritten = parsed.to_string(); + if !had_scheme { + rewritten = rewritten + .strip_prefix("http://") + .unwrap_or(rewritten.as_str()) + .to_string(); + } + if !proxy_url.ends_with('/') + && !proxy_url.contains('?') + && !proxy_url.contains('#') + && rewritten.ends_with('/') + { + rewritten.pop(); + } + Some(rewritten) +} + +fn create_proxy_socket_dir() -> io::Result { + let temp_dir = proxy_socket_parent_dir(); + let pid = std::process::id(); + for attempt in 0..128 { + let candidate = temp_dir.join(format!("{PROXY_SOCKET_DIR_PREFIX}{pid}-{attempt}")); + // The bridge UDS paths live under a shared temp root, so the per-run + // directory should not be traversable by other processes. + let mut dir_builder = DirBuilder::new(); + dir_builder.mode(0o700); + match dir_builder.create(&candidate) { + Ok(()) => return Ok(candidate), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "failed to allocate proxy routing temp dir under {}", + temp_dir.display() + ), + )) +} + +fn proxy_socket_parent_dir() -> PathBuf { + if let Some(codex_home) = std::env::var_os("CODEX_HOME") { + let candidate = PathBuf::from(codex_home).join("tmp"); + if ensure_private_proxy_socket_parent_dir(candidate.as_path()).is_ok() { + return candidate; + } + } + std::env::temp_dir() +} + +fn ensure_private_proxy_socket_parent_dir(path: &Path) -> io::Result<()> { + let mut dir_builder = DirBuilder::new(); + dir_builder.recursive(true); + dir_builder.mode(0o700); + match dir_builder.create(path) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {} + Err(err) => return Err(err), + } + std::fs::set_permissions(path, Permissions::from_mode(0o700)) +} + +fn cleanup_stale_proxy_socket_dirs_in(temp_dir: &Path) -> io::Result<()> { + for entry in std::fs::read_dir(temp_dir)? { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(_) => continue, + }; + if !file_type.is_dir() { + continue; + } + + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + let Some(owner_pid) = parse_proxy_socket_dir_owner_pid(file_name.as_ref()) else { + continue; + }; + if is_pid_alive(owner_pid) { + continue; + } + + let _ = cleanup_proxy_socket_dir(entry.path().as_path()); + } + + Ok(()) +} + +fn parse_proxy_socket_dir_owner_pid(file_name: &str) -> Option { + let suffix = file_name.strip_prefix(PROXY_SOCKET_DIR_PREFIX)?; + let (pid_raw, _) = suffix.split_once('-')?; + pid_raw.parse::().ok().filter(|pid| *pid != 0) +} + +fn is_pid_alive(pid: u32) -> bool { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return false; + }; + is_pid_alive_raw(pid) +} + +fn is_pid_alive_raw(pid: libc::pid_t) -> bool { + let status = unsafe { libc::kill(pid, 0) }; + if status == 0 { + return true; + } + let err = io::Error::last_os_error(); + !matches!(err.raw_os_error(), Some(libc::ESRCH)) +} + +fn spawn_proxy_socket_dir_cleanup_worker( + socket_dir: PathBuf, + host_bridge_pids: Vec, +) -> io::Result<()> { + let pid = unsafe { libc::fork() }; + if pid < 0 { + return Err(io::Error::last_os_error()); + } + + if pid == 0 { + loop { + if host_bridge_pids + .iter() + .copied() + .all(|bridge_pid| !is_pid_alive_raw(bridge_pid)) + { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + + let _ = cleanup_proxy_socket_dir(socket_dir.as_path()); + unsafe { libc::_exit(0) }; + } + + Ok(()) +} + +fn cleanup_proxy_socket_dir(socket_dir: &Path) -> io::Result<()> { + for _ in 0..20 { + match std::fs::remove_dir_all(socket_dir) { + Ok(()) => return Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(_) => std::thread::sleep(Duration::from_millis(100)), + } + } + + match std::fs::remove_dir_all(socket_dir) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + +fn spawn_host_bridge(endpoint: SocketAddr, uds_path: &Path) -> io::Result { + let (read_fd, write_fd) = create_ready_pipe()?; + let pid = unsafe { libc::fork() }; + if pid < 0 { + let err = io::Error::last_os_error(); + close_fd(read_fd)?; + close_fd(write_fd)?; + return Err(err); + } + + if pid == 0 { + if close_fd(read_fd).is_err() { + unsafe { libc::_exit(1) }; + } + let result = run_host_bridge(endpoint, uds_path, write_fd); + if result.is_err() { + unsafe { libc::_exit(1) }; + } + unsafe { libc::_exit(0) }; + } + + close_fd(write_fd)?; + let mut ready = [0_u8; 1]; + let mut read_file = unsafe { File::from_raw_fd(read_fd) }; + read_file.read_exact(&mut ready)?; + if ready[0] != HOST_BRIDGE_READY { + return Err(io::Error::other( + "host bridge did not acknowledge readiness", + )); + } + Ok(pid) +} + +fn run_host_bridge(endpoint: SocketAddr, uds_path: &Path, ready_fd: libc::c_int) -> io::Result<()> { + set_parent_death_signal()?; + if uds_path.exists() { + std::fs::remove_file(uds_path)?; + } + let listener = UnixListener::bind(uds_path)?; + + let mut ready_file = unsafe { File::from_raw_fd(ready_fd) }; + ready_file.write_all(&[HOST_BRIDGE_READY])?; + drop(ready_file); + + loop { + let (unix_stream, _) = listener.accept()?; + std::thread::spawn(move || { + let tcp_stream = match TcpStream::connect(endpoint) { + Ok(stream) => stream, + Err(_) => return, + }; + let _ = proxy_bidirectional(tcp_stream, unix_stream); + }); + } +} + +fn spawn_local_bridge(uds_path: &Path) -> io::Result { + let (read_fd, write_fd) = create_ready_pipe()?; + let pid = unsafe { libc::fork() }; + if pid < 0 { + let err = io::Error::last_os_error(); + close_fd(read_fd)?; + close_fd(write_fd)?; + return Err(err); + } + + if pid == 0 { + if close_fd(read_fd).is_err() { + unsafe { libc::_exit(1) }; + } + let result = run_local_bridge(uds_path, write_fd); + if result.is_err() { + unsafe { libc::_exit(1) }; + } + unsafe { libc::_exit(0) }; + } + + close_fd(write_fd)?; + let mut port_bytes = [0_u8; 2]; + let mut read_file = unsafe { File::from_raw_fd(read_fd) }; + read_file.read_exact(&mut port_bytes)?; + Ok(u16::from_be_bytes(port_bytes)) +} + +fn run_local_bridge(uds_path: &Path, ready_fd: libc::c_int) -> io::Result<()> { + set_parent_death_signal()?; + let listener = bind_local_loopback_listener()?; + let port = listener.local_addr()?.port(); + + let mut ready_file = unsafe { File::from_raw_fd(ready_fd) }; + ready_file.write_all(&port.to_be_bytes())?; + drop(ready_file); + + let uds_path = uds_path.to_path_buf(); + loop { + let (tcp_stream, _) = listener.accept()?; + let socket_path = uds_path.clone(); + std::thread::spawn(move || { + let unix_stream = match UnixStream::connect(socket_path) { + Ok(stream) => stream, + Err(_) => return, + }; + let _ = proxy_bidirectional(tcp_stream, unix_stream); + }); + } +} + +fn bind_local_loopback_listener() -> io::Result { + match TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) { + Ok(listener) => Ok(listener), + Err(bind_err) => { + let should_retry_after_lo_up = matches!( + bind_err.raw_os_error(), + Some(errno) if errno == libc::EADDRNOTAVAIL || errno == libc::ENETUNREACH + ); + if !should_retry_after_lo_up { + return Err(bind_err); + } + + ensure_loopback_interface_up()?; + TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + } + } +} + +fn ensure_loopback_interface_up() -> io::Result<()> { + let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + + let mut ifreq = unsafe { std::mem::zeroed::() }; + for (index, byte) in LOOPBACK_INTERFACE_NAME.iter().copied().enumerate() { + ifreq.ifr_name[index] = byte as libc::c_char; + } + + let read_flags_result = + unsafe { libc::ioctl(fd, libc::SIOCGIFFLAGS as libc::Ioctl, &mut ifreq) }; + if read_flags_result < 0 { + let err = io::Error::last_os_error(); + let _ = close_fd(fd); + return Err(err); + } + + let current_flags = unsafe { ifreq.ifr_ifru.ifru_flags }; + let up_flag = libc::IFF_UP as libc::c_short; + if (current_flags & up_flag) != up_flag { + ifreq.ifr_ifru.ifru_flags = current_flags | up_flag; + let set_flags_result = + unsafe { libc::ioctl(fd, libc::SIOCSIFFLAGS as libc::Ioctl, &ifreq) }; + if set_flags_result < 0 { + let err = io::Error::last_os_error(); + let _ = close_fd(fd); + return Err(err); + } + } + + let mut addr_req = unsafe { std::mem::zeroed::() }; + for (index, byte) in LOOPBACK_INTERFACE_NAME.iter().copied().enumerate() { + addr_req.ifr_name[index] = byte as libc::c_char; + } + let loopback_addr = libc::sockaddr_in { + sin_family: libc::AF_INET as libc::sa_family_t, + sin_port: 0, + sin_addr: libc::in_addr { + s_addr: libc::htonl(libc::INADDR_LOOPBACK), + }, + sin_zero: [0; 8], + }; + unsafe { + addr_req.ifr_ifru.ifru_addr = + *(&loopback_addr as *const libc::sockaddr_in as *const libc::sockaddr); + } + let set_addr_result = unsafe { libc::ioctl(fd, libc::SIOCSIFADDR as libc::Ioctl, &addr_req) }; + if set_addr_result < 0 { + let err = io::Error::last_os_error(); + let allow_existing_or_immutable_addr = + matches!(err.raw_os_error(), Some(libc::EEXIST | libc::EPERM)); + if !allow_existing_or_immutable_addr { + let _ = close_fd(fd); + return Err(err); + } + } + + close_fd(fd) +} + +fn set_parent_death_signal() -> io::Result<()> { + let res = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) }; + if res != 0 { + Err(io::Error::last_os_error()) + } else if unsafe { libc::getppid() } == 1 { + Err(io::Error::other("parent process already exited")) + } else { + Ok(()) + } +} + +fn proxy_bidirectional(mut tcp_stream: TcpStream, mut unix_stream: UnixStream) -> io::Result<()> { + let mut tcp_reader = tcp_stream.try_clone()?; + let mut unix_writer = unix_stream.try_clone()?; + let tcp_to_unix = std::thread::spawn(move || std::io::copy(&mut tcp_reader, &mut unix_writer)); + let unix_to_tcp = std::io::copy(&mut unix_stream, &mut tcp_stream); + let tcp_to_unix = tcp_to_unix + .join() + .map_err(|_| io::Error::other("bridge thread panicked"))?; + tcp_to_unix?; + unix_to_tcp?; + Ok(()) +} + +fn create_ready_pipe() -> io::Result<(libc::c_int, libc::c_int)> { + let mut pipe_fds = [0; 2]; + let res = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) }; + if res != 0 { + return Err(io::Error::last_os_error()); + } + Ok((pipe_fds[0], pipe_fds[1])) +} + +fn close_fd(fd: libc::c_int) -> io::Result<()> { + let res = unsafe { libc::close(fd) }; + if res < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::PROXY_SOCKET_DIR_PREFIX; + use super::ProxyRouteEntry; + use super::ProxyRouteSpec; + use super::cleanup_proxy_socket_dir; + use super::cleanup_stale_proxy_socket_dirs_in; + use super::default_proxy_port; + use super::is_proxy_env_key; + use super::parse_loopback_proxy_endpoint; + use super::parse_proxy_socket_dir_owner_pid; + use super::plan_proxy_routes; + use super::rewrite_proxy_env_value; + use pretty_assertions::assert_eq; + use std::collections::HashMap; + use std::net::SocketAddr; + use std::path::PathBuf; + + #[test] + fn recognizes_proxy_env_keys_case_insensitively() { + assert_eq!(is_proxy_env_key("HTTP_PROXY"), true); + assert_eq!(is_proxy_env_key("http_proxy"), true); + assert_eq!(is_proxy_env_key("PATH"), false); + } + + #[test] + fn parses_loopback_proxy_endpoint() { + let endpoint = parse_loopback_proxy_endpoint("http://127.0.0.1:43128"); + assert_eq!( + endpoint, + Some( + "127.0.0.1:43128" + .parse::() + .expect("valid socket") + ) + ); + } + + #[test] + fn ignores_non_loopback_proxy_endpoint() { + assert_eq!( + parse_loopback_proxy_endpoint("http://example.com:3128"), + None + ); + } + + #[test] + fn plan_proxy_routes_only_includes_valid_loopback_endpoints() { + let mut env = HashMap::new(); + env.insert( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43128".to_string(), + ); + env.insert( + "HTTPS_PROXY".to_string(), + "http://example.com:3128".to_string(), + ); + env.insert("PATH".to_string(), "/usr/bin".to_string()); + + let plan = plan_proxy_routes(&env); + assert_eq!(plan.has_proxy_config, true); + assert_eq!(plan.routes.len(), 1); + assert_eq!(plan.routes[0].env_key, "HTTP_PROXY"); + assert_eq!( + plan.routes[0].endpoint, + "127.0.0.1:43128" + .parse::() + .expect("valid socket") + ); + } + + #[test] + fn rewrites_proxy_url_to_local_loopback_port() { + let rewritten = + rewrite_proxy_env_value("socks5h://127.0.0.1:8081", 43210).expect("rewritten value"); + assert_eq!(rewritten, "socks5h://127.0.0.1:43210"); + } + + #[test] + fn default_proxy_ports_match_expected_schemes() { + assert_eq!(default_proxy_port("http"), 80); + assert_eq!(default_proxy_port("https"), 443); + assert_eq!(default_proxy_port("socks5h"), 1080); + } + + #[test] + fn cleanup_proxy_socket_dir_removes_bridge_artifacts() { + let root = tempfile::tempdir().expect("tempdir should create"); + let socket_dir = root.path().join("codex-linux-sandbox-proxy-test"); + std::fs::create_dir(&socket_dir).expect("socket dir should create"); + let marker = socket_dir.join("bridge.sock"); + std::fs::write(&marker, b"test").expect("marker should write"); + + cleanup_proxy_socket_dir(socket_dir.as_path()).expect("cleanup should succeed"); + + assert_eq!(socket_dir.exists(), false); + } + + #[test] + fn proxy_route_spec_serialization_omits_proxy_urls() { + let spec = ProxyRouteSpec { + routes: vec![ProxyRouteEntry { + env_key: "HTTP_PROXY".to_string(), + uds_path: PathBuf::from("/tmp/proxy-route-0.sock"), + }], + }; + let serialized = serde_json::to_string(&spec).expect("proxy route spec should serialize"); + + assert_eq!( + serialized, + r#"{"routes":[{"env_key":"HTTP_PROXY","uds_path":"/tmp/proxy-route-0.sock"}]}"# + ); + } + + #[test] + fn parse_proxy_socket_dir_owner_pid_reads_owner_pid() { + assert_eq!( + parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-0"), + Some(1234) + ); + assert_eq!( + parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-x"), + None + ); + assert_eq!(parse_proxy_socket_dir_owner_pid("not-a-proxy-dir"), None); + } + + #[test] + fn cleanup_stale_proxy_socket_dirs_removes_dead_pid_directories() { + let root = tempfile::tempdir().expect("tempdir should create"); + let dead_dir = root + .path() + .join(format!("{PROXY_SOCKET_DIR_PREFIX}{}-0", u32::MAX)); + std::fs::create_dir(&dead_dir).expect("dead dir should create"); + + let alive_dir = root + .path() + .join(format!("{PROXY_SOCKET_DIR_PREFIX}{}-1", std::process::id())); + std::fs::create_dir(&alive_dir).expect("alive dir should create"); + + let unrelated_dir = root.path().join("unrelated-proxy-dir"); + std::fs::create_dir(&unrelated_dir).expect("unrelated dir should create"); + + cleanup_stale_proxy_socket_dirs_in(root.path()).expect("stale cleanup should succeed"); + + assert_eq!(dead_dir.exists(), false); + assert_eq!(alive_dir.exists(), true); + assert_eq!(unrelated_dir.exists(), true); + } +} diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs new file mode 100644 index 0000000000..e27930cdb5 --- /dev/null +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -0,0 +1,313 @@ +#![cfg(target_os = "linux")] +#![allow(clippy::unwrap_used)] + +use codex_core::config::types::ShellEnvironmentPolicy; +use codex_core::exec_env::create_env; +use codex_protocol::protocol::SandboxPolicy; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::io::Read; +use std::io::Write; +use std::net::Ipv4Addr; +use std::net::TcpListener; +use std::process::Output; +use std::process::Stdio; +use std::time::Duration; +use tokio::process::Command; + +const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build."; +const NETWORK_TIMEOUT_MS: u64 = 4_000; +const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[ + "loopback: Failed RTM_NEWADDR", + "loopback: Failed RTM_NEWLINK", + "setting up uid map: Permission denied", + "No permissions to create a new namespace", + "error isolating Linux network namespace for proxy mode", +]; + +const PROXY_ENV_KEYS: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "FTP_PROXY", + "YARN_HTTP_PROXY", + "YARN_HTTPS_PROXY", + "NPM_CONFIG_HTTP_PROXY", + "NPM_CONFIG_HTTPS_PROXY", + "NPM_CONFIG_PROXY", + "BUNDLE_HTTP_PROXY", + "BUNDLE_HTTPS_PROXY", + "PIP_PROXY", + "DOCKER_HTTP_PROXY", + "DOCKER_HTTPS_PROXY", +]; + +fn create_env_from_core_vars() -> HashMap { + let policy = ShellEnvironmentPolicy::default(); + create_env(&policy, None) +} + +fn strip_proxy_env(env: &mut HashMap) { + for key in PROXY_ENV_KEYS { + env.remove(*key); + let lower = key.to_ascii_lowercase(); + env.remove(lower.as_str()); + } +} + +fn is_bwrap_unavailable_output(output: &Output) -> bool { + String::from_utf8_lossy(&output.stderr).contains(BWRAP_UNAVAILABLE_ERR) +} + +async fn should_skip_bwrap_tests() -> bool { + let mut env = create_env_from_core_vars(); + strip_proxy_env(&mut env); + + let output = run_linux_sandbox_direct( + &["bash", "-c", "true"], + &SandboxPolicy::new_read_only_policy(), + false, + env, + NETWORK_TIMEOUT_MS, + ) + .await; + is_bwrap_unavailable_output(&output) +} + +fn is_managed_proxy_permission_error(stderr: &str) -> bool { + MANAGED_PROXY_PERMISSION_ERR_SNIPPETS + .iter() + .any(|snippet| stderr.contains(snippet)) +} + +async fn managed_proxy_skip_reason() -> Option { + if should_skip_bwrap_tests().await { + return Some("vendored bwrap was not built in this environment".to_string()); + } + + let mut env = create_env_from_core_vars(); + strip_proxy_env(&mut env); + env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string()); + + let output = run_linux_sandbox_direct( + &["bash", "-c", "true"], + &SandboxPolicy::DangerFullAccess, + true, + env, + NETWORK_TIMEOUT_MS, + ) + .await; + if output.status.success() { + return None; + } + + let stderr = String::from_utf8_lossy(&output.stderr); + if is_managed_proxy_permission_error(stderr.as_ref()) { + return Some(format!( + "managed proxy requires kernel namespace privileges unavailable here: {}", + stderr.trim() + )); + } + + None +} + +async fn run_linux_sandbox_direct( + command: &[&str], + sandbox_policy: &SandboxPolicy, + allow_network_for_proxy: bool, + env: HashMap, + timeout_ms: u64, +) -> Output { + let cwd = match std::env::current_dir() { + Ok(cwd) => cwd, + Err(err) => panic!("cwd should exist: {err}"), + }; + let policy_json = match serde_json::to_string(sandbox_policy) { + Ok(policy_json) => policy_json, + Err(err) => panic!("policy should serialize: {err}"), + }; + + let mut args = vec![ + "--sandbox-policy-cwd".to_string(), + cwd.to_string_lossy().to_string(), + "--sandbox-policy".to_string(), + policy_json, + "--use-bwrap-sandbox".to_string(), + ]; + if allow_network_for_proxy { + args.push("--allow-network-for-proxy".to_string()); + } + args.push("--".to_string()); + args.extend(command.iter().map(|entry| (*entry).to_string())); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_codex-linux-sandbox")); + cmd.args(args) + .current_dir(cwd) + .env_clear() + .envs(env) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), cmd.output()).await { + Ok(output) => output, + Err(err) => panic!("sandbox command should not time out: {err}"), + }; + match output { + Ok(output) => output, + Err(err) => panic!("sandbox command should execute: {err}"), + } +} + +#[tokio::test] +async fn managed_proxy_mode_fails_closed_without_proxy_env() { + if let Some(skip_reason) = managed_proxy_skip_reason().await { + eprintln!("skipping managed proxy test: {skip_reason}"); + return; + } + + let mut env = create_env_from_core_vars(); + strip_proxy_env(&mut env); + + let output = run_linux_sandbox_direct( + &["bash", "-c", "true"], + &SandboxPolicy::DangerFullAccess, + true, + env, + NETWORK_TIMEOUT_MS, + ) + .await; + + assert_eq!(output.status.success(), false); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("managed proxy mode requires proxy environment variables"), + "expected fail-closed managed-proxy message, got stderr: {stderr}" + ); +} + +#[tokio::test] +async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() { + if let Some(skip_reason) = managed_proxy_skip_reason().await { + eprintln!("skipping managed proxy test: {skip_reason}"); + return; + } + + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind proxy listener"); + let proxy_port = listener + .local_addr() + .expect("proxy listener local addr") + .port(); + let (request_tx, request_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept proxy connection"); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("set read timeout"); + let mut buf = [0_u8; 4096]; + let read = stream.read(&mut buf).expect("read proxy request"); + let request = String::from_utf8_lossy(&buf[..read]).to_string(); + request_tx.send(request).expect("send proxy request"); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK") + .expect("write proxy response"); + }); + + let mut env = create_env_from_core_vars(); + strip_proxy_env(&mut env); + env.insert( + "HTTP_PROXY".to_string(), + format!("http://127.0.0.1:{proxy_port}"), + ); + + let routed_output = run_linux_sandbox_direct( + &[ + "bash", + "-c", + "proxy=\"${HTTP_PROXY#*://}\"; host=\"${proxy%%:*}\"; port=\"${proxy##*:}\"; exec 3<>/dev/tcp/${host}/${port}; printf 'GET http://example.com/ HTTP/1.1\\r\\nHost: example.com\\r\\n\\r\\n' >&3; IFS= read -r line <&3; printf '%s\\n' \"$line\"", + ], + &SandboxPolicy::DangerFullAccess, + true, + env.clone(), + NETWORK_TIMEOUT_MS, + ) + .await; + + assert_eq!( + routed_output.status.success(), + true, + "expected routed command to execute successfully; status={:?}; stdout={}; stderr={}", + routed_output.status.code(), + String::from_utf8_lossy(&routed_output.stdout), + String::from_utf8_lossy(&routed_output.stderr) + ); + let stdout = String::from_utf8_lossy(&routed_output.stdout); + assert!( + stdout.contains("HTTP/1.1 200 OK"), + "expected bridge-routed proxy response, got stdout: {stdout}" + ); + + let request = request_rx + .recv_timeout(Duration::from_secs(3)) + .expect("expected proxy request"); + assert!( + request.contains("GET http://example.com/ HTTP/1.1"), + "expected HTTP proxy absolute-form request, got request: {request}" + ); + + let direct_egress_output = run_linux_sandbox_direct( + &["bash", "-c", "echo hi > /dev/tcp/192.0.2.1/80"], + &SandboxPolicy::DangerFullAccess, + true, + env, + NETWORK_TIMEOUT_MS, + ) + .await; + assert_eq!(direct_egress_output.status.success(), false); +} + +#[tokio::test] +async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() { + if let Some(skip_reason) = managed_proxy_skip_reason().await { + eprintln!("skipping managed proxy test: {skip_reason}"); + return; + } + + let python_available = Command::new("bash") + .arg("-c") + .arg("command -v python3 >/dev/null") + .status() + .await + .expect("python3 probe should execute") + .success(); + if !python_available { + eprintln!("skipping managed proxy AF_UNIX test: python3 is unavailable"); + return; + } + + let mut env = create_env_from_core_vars(); + strip_proxy_env(&mut env); + env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string()); + + let output = run_linux_sandbox_direct( + &[ + "python3", + "-c", + "import socket,sys\ntry:\n socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\nexcept PermissionError:\n sys.exit(0)\nexcept OSError:\n sys.exit(2)\nsys.exit(1)\n", + ], + &SandboxPolicy::DangerFullAccess, + true, + env, + NETWORK_TIMEOUT_MS, + ) + .await; + + assert_eq!( + output.status.code(), + Some(0), + "expected AF_UNIX creation to be denied cleanly for user command; status={:?}; stdout={}; stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/codex-rs/linux-sandbox/tests/suite/mod.rs b/codex-rs/linux-sandbox/tests/suite/mod.rs index d2a6bfa148..8ef44a61a4 100644 --- a/codex-rs/linux-sandbox/tests/suite/mod.rs +++ b/codex-rs/linux-sandbox/tests/suite/mod.rs @@ -1,2 +1,3 @@ // Aggregates all former standalone integration tests as modules. mod landlock; +mod managed_proxy; From 5a635f3427101033564070a35a5e99ae534cd941 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Sat, 21 Feb 2026 11:39:02 -0800 Subject: [PATCH 038/282] profile-level model_catalog_json overrie (#12410) enable `model-catalog_json` config value on `ConfigProfile` as well --- codex-rs/core/config.schema.json | 8 ++++++++ codex-rs/core/src/config/mod.rs | 7 ++++++- codex-rs/core/src/config/profile.rs | 2 ++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 23963196a1..addbcbe313 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -440,6 +440,14 @@ "model": { "type": "string" }, + "model_catalog_json": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Optional path to a JSON model catalog (applied on startup only)." + }, "model_instructions_file": { "allOf": [ { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ca3978b73d..ddc5aa9cdd 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1908,7 +1908,12 @@ impl Config { let review_model = override_review_model.or(cfg.review_model); let check_for_update_on_startup = cfg.check_for_update_on_startup.unwrap_or(true); - let model_catalog = load_model_catalog(cfg.model_catalog_json.clone())?; + let model_catalog = load_model_catalog( + config_profile + .model_catalog_json + .clone() + .or(cfg.model_catalog_json.clone()), + )?; let log_dir = cfg .log_dir diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 3c7d575cdc..61b02adcd0 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -27,6 +27,8 @@ pub struct ConfigProfile { pub plan_mode_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, + /// Optional path to a JSON model catalog (applied on startup only). + pub model_catalog_json: Option, pub personality: Option, pub chatgpt_base_url: Option, /// Optional path to a file containing model instructions. From a6b2bacb5b8bb485a52d979d6d53bf1e639811d7 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 21 Feb 2026 11:55:03 -0800 Subject: [PATCH 039/282] Prevent replayed runtime events from forcing active status (#12420) Fixes #11852 Resume replay was applying transient runtime events (`TurnStarted`, `StreamError`) as if they were live, which could leave the TUI stuck in a stale `Working` / `Reconnecting...` state after resuming an interrupted reconnect. This change makes replay transcript-oriented for these events by: - skipping retry-status restoration for replayed non-stream events - ignoring replayed `TurnStarted` for task-running state - ignoring replayed `StreamError` for reconnect/status UI Also adds TUI regression tests and snapshot coverage for the interrupted reconnect replay case. --- codex-rs/tui/src/chatwidget.rs | 36 +++- ...ayed_interrupted_reconnect_footer_row.snap | 6 + codex-rs/tui/src/chatwidget/tests.rs | 154 ++++++++++++++++++ 3 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__replayed_interrupted_reconnect_footer_row.snap diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 238a60730c..9bfddd18e4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -832,6 +832,12 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ReplayKind { + ResumeInitialMessages, + ThreadSnapshot, +} + impl ChatWidget { /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. /// @@ -4002,13 +4008,13 @@ impl ChatWidget { continue; } // `id: None` indicates a synthetic/fake id coming from replay. - self.dispatch_event_msg(None, msg, true); + self.dispatch_event_msg(None, msg, Some(ReplayKind::ResumeInitialMessages)); } } pub(crate) fn handle_codex_event(&mut self, event: Event) { let Event { id, msg } = event; - self.dispatch_event_msg(Some(id), msg, false); + self.dispatch_event_msg(Some(id), msg, None); } pub(crate) fn handle_codex_event_replay(&mut self, event: Event) { @@ -4016,7 +4022,7 @@ impl ChatWidget { if matches!(msg, EventMsg::ShutdownComplete) { return; } - self.dispatch_event_msg(None, msg, true); + self.dispatch_event_msg(None, msg, Some(ReplayKind::ThreadSnapshot)); } /// Dispatch a protocol `EventMsg` to the appropriate handler. @@ -4024,9 +4030,17 @@ impl ChatWidget { /// `id` is `Some` for live events and `None` for replayed events from /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id /// that must not be used to correlate follow-up actions. - fn dispatch_event_msg(&mut self, id: Option, msg: EventMsg, from_replay: bool) { + fn dispatch_event_msg( + &mut self, + id: Option, + msg: EventMsg, + replay_kind: Option, + ) { + let from_replay = replay_kind.is_some(); + let is_resume_initial_replay = + matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)); let is_stream_error = matches!(&msg, EventMsg::StreamError(_)); - if !is_stream_error { + if !is_resume_initial_replay && !is_stream_error { self.restore_retry_status_header_if_present(); } @@ -4061,7 +4075,11 @@ impl ChatWidget { self.on_agent_reasoning_final(); } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), - EventMsg::TurnStarted(_) => self.on_task_started(), + EventMsg::TurnStarted(_) => { + if !is_resume_initial_replay { + self.on_task_started(); + } + } EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message, .. }) => self.on_task_complete(last_agent_message, from_replay), @@ -4151,7 +4169,11 @@ impl ChatWidget { message, additional_details, .. - }) => self.on_stream_error(message, additional_details), + }) => { + if !is_resume_initial_replay { + self.on_stream_error(message, additional_details); + } + } EventMsg::UserMessage(ev) => { if from_replay { self.on_user_message_event(ev); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__replayed_interrupted_reconnect_footer_row.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__replayed_interrupted_reconnect_footer_row.snap new file mode 100644 index 0000000000..eb3183f574 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__replayed_interrupted_reconnect_footer_row.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 7060 +expression: header +--- +› Ask Codex to do anything diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index b9f5ddf814..e634d3ba39 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -7228,6 +7228,160 @@ async fn stream_error_updates_status_indicator() { assert_eq!(status.details(), Some(details)); } +#[tokio::test] +async fn replayed_turn_started_does_not_mark_task_running() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + })]); + + assert!(!chat.bottom_pane.is_task_running()); + assert!(chat.bottom_pane.status_widget().is_none()); +} + +#[tokio::test] +async fn thread_snapshot_replayed_turn_started_marks_task_running() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event_replay(Event { + id: "turn-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + drain_insert_history(&mut rx); + assert!(chat.bottom_pane.is_task_running()); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Working"); +} + +#[tokio::test] +async fn replayed_stream_error_does_not_set_retry_status_or_status_indicator() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_status_header("Idle".to_string()); + + chat.replay_initial_messages(vec![EventMsg::StreamError(StreamErrorEvent { + message: "Reconnecting... 2/5".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + additional_details: Some("Idle timeout waiting for SSE".to_string()), + })]); + + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected no history cell for replayed StreamError event" + ); + assert_eq!(chat.current_status_header, "Idle"); + assert!(chat.retry_status_header.is_none()); + assert!(chat.bottom_pane.status_widget().is_none()); +} + +#[tokio::test] +async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event_replay(Event { + id: "task".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + drain_insert_history(&mut rx); + + chat.handle_codex_event_replay(Event { + id: "retry".into(), + msg: EventMsg::StreamError(StreamErrorEvent { + message: "Reconnecting... 1/5".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + additional_details: None, + }), + }); + drain_insert_history(&mut rx); + + chat.handle_codex_event_replay(Event { + id: "delta".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "hello".to_string(), + }), + }); + + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Working"); + assert_eq!(status.details(), None); + assert!(chat.retry_status_header.is_none()); +} + +#[tokio::test] +async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_status_header("Idle".to_string()); + + chat.replay_initial_messages(vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + EventMsg::StreamError(StreamErrorEvent { + message: "Reconnecting... 1/5".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + additional_details: None, + }), + EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "hello".to_string(), + }), + ]); + + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected no history cells for replayed interrupted reconnect sequence" + ); + assert!(!chat.bottom_pane.is_task_running()); + assert!(chat.bottom_pane.status_widget().is_none()); + assert_eq!(chat.current_status_header, "Idle"); + assert!(chat.retry_status_header.is_none()); +} + +#[tokio::test] +async fn replayed_interrupted_reconnect_footer_row_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.replay_initial_messages(vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + EventMsg::StreamError(StreamErrorEvent { + message: "Reconnecting... 2/5".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + additional_details: Some("Idle timeout waiting for SSE".to_string()), + }), + ]); + + let header = render_bottom_first_row(&chat, 80); + assert!( + !header.contains("Reconnecting") && !header.contains("Working"), + "expected replayed interrupted reconnect to avoid active status row, got {header:?}" + ); + assert_snapshot!("replayed_interrupted_reconnect_footer_row", header); +} + #[tokio::test] async fn stream_error_restores_hidden_status_indicator() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; From b17148f13a2e261d36884ab3fa105d235426bf5a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 21 Feb 2026 12:08:04 -0800 Subject: [PATCH 040/282] Prefer v2 websockets if available (#12428) And also cleanup settings flow to avoid reading many separate flags. --------- Co-authored-by: Codex --- codex-rs/core/src/client.rs | 136 ++++++++++-------- codex-rs/core/src/codex.rs | 20 +-- codex-rs/core/src/lib.rs | 2 + codex-rs/core/tests/responses_headers.rs | 10 +- codex-rs/core/tests/suite/client.rs | 4 +- .../core/tests/suite/client_websockets.rs | 60 +++++++- 6 files changed, 153 insertions(+), 79 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 6f33910ce7..5239841041 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -92,6 +92,7 @@ use crate::auth::RefreshTokenError; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; +use crate::config::Config; use crate::default_client::build_reqwest_client; use crate::error::CodexErr; use crate::error::Result; @@ -107,6 +108,28 @@ pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata"; pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str = "x-responsesapi-include-timing-metrics"; const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResponsesWebsocketVersion { + V1, + V2, +} + +pub fn ws_version_from_features(config: &Config) -> Option { + match ( + config + .features + .enabled(crate::features::Feature::ResponsesWebsockets), + config + .features + .enabled(crate::features::Feature::ResponsesWebsocketsV2), + ) { + (_, true) => Some(ResponsesWebsocketVersion::V2), + (true, false) => Some(ResponsesWebsocketVersion::V1), + (false, false) => None, + } +} + /// Session-scoped state shared by all [`ModelClient`] clones. /// /// This is intentionally kept minimal so `ModelClient` does not need to hold a full `Config`. Most @@ -118,8 +141,7 @@ struct ModelClientState { provider: ModelProviderInfo, session_source: SessionSource, model_verbosity: Option, - enable_responses_websockets: bool, - enable_responses_websockets_v2: bool, + responses_websocket_version: Option, enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, @@ -209,14 +231,11 @@ impl ModelClient { provider: ModelProviderInfo, session_source: SessionSource, model_verbosity: Option, - enable_responses_websockets: bool, - enable_responses_websockets_v2: bool, + responses_websocket_version: Option, enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, ) -> Self { - let enable_responses_websockets = - enable_responses_websockets || enable_responses_websockets_v2; Self { state: Arc::new(ModelClientState { auth_manager, @@ -224,8 +243,7 @@ impl ModelClient { provider, session_source, model_verbosity, - enable_responses_websockets, - enable_responses_websockets_v2, + responses_websocket_version, enable_request_compression, include_timing_metrics, beta_features_header, @@ -367,26 +385,25 @@ impl ModelClient { request_telemetry } - /// Returns whether this session is configured to use Responses-over-WebSocket. + /// Returns the active Responses-over-WebSocket version for this session. /// /// This combines provider capability and feature gating; both must be true for websocket paths /// to be eligible. - pub fn responses_websocket_enabled(&self, model_info: &ModelInfo) -> bool { - self.state.provider.supports_websockets - && (self.state.enable_responses_websockets - || self.state.enable_responses_websockets_v2 - || model_info.prefer_websockets) - } - - fn responses_websockets_v2_enabled(&self) -> bool { - self.state.enable_responses_websockets_v2 - } - - /// Returns whether websocket transport has been permanently disabled for this session. /// - /// Once set by fallback activation, subsequent turns must stay on HTTP transport. - fn websockets_disabled(&self) -> bool { - self.state.disable_websockets.load(Ordering::Relaxed) + /// If websockets are only enabled via model preference (no explicit feature flag), default to + /// v1 behavior. + pub fn active_ws_version(&self, model_info: &ModelInfo) -> Option { + if !self.state.provider.supports_websockets + || self.state.disable_websockets.load(Ordering::Relaxed) + { + return None; + } + + match self.state.responses_websocket_version { + Some(version) => Some(version), + None if model_info.prefer_websockets => Some(ResponsesWebsocketVersion::V1), + None => None, + } } /// Returns auth + provider configuration resolved from the current session auth state. @@ -419,10 +436,12 @@ impl ModelClient { otel_manager: &OtelManager, api_provider: codex_api::Provider, api_auth: CoreAuthProvider, + ws_version: ResponsesWebsocketVersion, turn_state: Option>>, turn_metadata_header: Option<&str>, ) -> std::result::Result { - let headers = self.build_websocket_headers(turn_state.as_ref(), turn_metadata_header); + let headers = + self.build_websocket_headers(ws_version, turn_state.as_ref(), turn_metadata_header); let websocket_telemetry = ModelClientSession::build_websocket_telemetry(otel_manager); ApiWebSocketResponsesClient::new(api_provider, api_auth) .connect( @@ -440,6 +459,7 @@ impl ModelClient { /// replayed on reconnect within the same turn. fn build_websocket_headers( &self, + ws_version: ResponsesWebsocketVersion, turn_state: Option<&Arc>>, turn_metadata_header: Option<&str>, ) -> ApiHeaderMap { @@ -452,10 +472,9 @@ impl ModelClient { headers.extend(build_conversation_headers(Some( self.state.conversation_id.to_string(), ))); - let responses_websockets_beta_header = if self.responses_websockets_v2_enabled() { - RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE - } else { - OPENAI_BETA_RESPONSES_WEBSOCKETS + let responses_websockets_beta_header = match ws_version { + ResponsesWebsocketVersion::V2 => RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE, + ResponsesWebsocketVersion::V1 => OPENAI_BETA_RESPONSES_WEBSOCKETS, }; headers.insert( OPENAI_BETA_HEADER, @@ -628,35 +647,39 @@ impl ModelClientSession { &mut self, payload: ResponseCreateWsRequest, request: &ResponsesApiRequest, + ws_version: ResponsesWebsocketVersion, ) -> ResponsesWsRequest { let Some(last_response) = self.get_last_response() else { return ResponsesWsRequest::ResponseCreate(payload); }; - let responses_websockets_v2_enabled = self.client.responses_websockets_v2_enabled(); - if !responses_websockets_v2_enabled && !last_response.can_append { - trace!("incremental request failed, can't append"); + let Some(append_items) = self.get_incremental_items(request, Some(&last_response)) else { return ResponsesWsRequest::ResponseCreate(payload); - } - let incremental_items = self.get_incremental_items(request, Some(&last_response)); - if let Some(append_items) = incremental_items { - if responses_websockets_v2_enabled && !last_response.response_id.is_empty() { - let payload = ResponseCreateWsRequest { + }; + + match ws_version { + ResponsesWebsocketVersion::V2 => { + if last_response.response_id.is_empty() { + trace!("incremental request failed, no previous response id"); + return ResponsesWsRequest::ResponseCreate(payload); + } + + ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest { previous_response_id: Some(last_response.response_id), input: append_items, ..payload - }; - return ResponsesWsRequest::ResponseCreate(payload); + }) } - - if !responses_websockets_v2_enabled { - return ResponsesWsRequest::ResponseAppend(ResponseAppendWsRequest { + ResponsesWebsocketVersion::V1 => { + if !last_response.can_append { + trace!("incremental request failed, can't append"); + return ResponsesWsRequest::ResponseCreate(payload); + } + ResponsesWsRequest::ResponseAppend(ResponseAppendWsRequest { input: append_items, client_metadata: payload.client_metadata, - }); + }) } } - - ResponsesWsRequest::ResponseCreate(payload) } /// Opportunistically warms a websocket for this turn-scoped client session. @@ -667,10 +690,9 @@ impl ModelClientSession { otel_manager: &OtelManager, model_info: &ModelInfo, ) -> std::result::Result<(), ApiError> { - if !self.client.responses_websocket_enabled(model_info) || self.client.websockets_disabled() - { + let Some(ws_version) = self.client.active_ws_version(model_info) else { return Ok(()); - } + }; if self.connection.is_some() { return Ok(()); } @@ -687,6 +709,7 @@ impl ModelClientSession { otel_manager, client_setup.api_provider, client_setup.api_auth, + ws_version, Some(Arc::clone(&self.turn_state)), None, ) @@ -701,6 +724,7 @@ impl ModelClientSession { otel_manager: &OtelManager, api_provider: codex_api::Provider, api_auth: CoreAuthProvider, + ws_version: ResponsesWebsocketVersion, turn_metadata_header: Option<&str>, options: &ApiResponsesOptions, ) -> std::result::Result<&ApiWebSocketConnection, ApiError> { @@ -722,6 +746,7 @@ impl ModelClientSession { otel_manager, api_provider, api_auth, + ws_version, Some(turn_state), turn_metadata_header, ) @@ -818,6 +843,7 @@ impl ModelClientSession { &mut self, prompt: &Prompt, model_info: &ModelInfo, + ws_version: ResponsesWebsocketVersion, otel_manager: &OtelManager, effort: Option, summary: ReasoningSummaryConfig, @@ -850,6 +876,7 @@ impl ModelClientSession { otel_manager, client_setup.api_provider, client_setup.api_auth, + ws_version, turn_metadata_header, &options, ) @@ -870,7 +897,7 @@ impl ModelClientSession { Err(err) => return Err(map_api_error(err)), } - let ws_request = self.prepare_websocket_request(ws_payload, &request); + let ws_request = self.prepare_websocket_request(ws_payload, &request, ws_version); let stream_result = self .connection @@ -928,14 +955,12 @@ impl ModelClientSession { let wire_api = self.client.state.provider.wire_api; match wire_api { WireApi::Responses => { - let websocket_enabled = self.client.responses_websocket_enabled(model_info) - && !self.client.websockets_disabled(); - - if websocket_enabled { + if let Some(ws_version) = self.client.active_ws_version(model_info) { match self .stream_responses_websocket( prompt, model_info, + ws_version, otel_manager, effort, summary, @@ -974,7 +999,7 @@ impl ModelClientSession { otel_manager: &OtelManager, model_info: &ModelInfo, ) -> bool { - let websocket_enabled = self.client.responses_websocket_enabled(model_info); + let websocket_enabled = self.client.active_ws_version(model_info).is_some(); let activated = self.activate_http_fallback(websocket_enabled); if activated { warn!("falling back to HTTP"); @@ -1224,8 +1249,7 @@ mod tests { provider, session_source, None, - false, - false, + None, false, false, None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6c9c9a678f..55a8062187 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -45,6 +45,7 @@ use crate::terminal; use crate::truncate::TruncationPolicy; use crate::turn_metadata::TurnMetadataState; use crate::util::error_or_panic; +use crate::ws_version_from_features; use async_channel::Receiver; use async_channel::Sender; use codex_hooks::HookEvent; @@ -1335,9 +1336,7 @@ impl Session { session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, - config.features.enabled(Feature::ResponsesWebsockets) - || config.features.enabled(Feature::ResponsesWebsocketsV2), - config.features.enabled(Feature::ResponsesWebsocketsV2), + ws_version_from_features(config.as_ref()), config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), @@ -5198,10 +5197,11 @@ async fn run_sampling_request( // transient reconnect messages. In debug builds, keep full visibility for diagnosis. let report_error = retries > 1 || cfg!(debug_assertions) - || !sess + || sess .services .model_client - .responses_websocket_enabled(&turn_context.model_info); + .active_ws_version(&turn_context.model_info) + .is_none(); if report_error { // Surface retry information to any UI/front‑end so the @@ -7844,10 +7844,7 @@ mod tests { session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, - model_info.prefer_websockets - || config.features.enabled(Feature::ResponsesWebsockets) - || config.features.enabled(Feature::ResponsesWebsocketsV2), - config.features.enabled(Feature::ResponsesWebsocketsV2), + ws_version_from_features(config.as_ref()), config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), @@ -8000,10 +7997,7 @@ mod tests { session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, - model_info.prefer_websockets - || config.features.enabled(Feature::ResponsesWebsockets) - || config.features.enabled(Feature::ResponsesWebsocketsV2), - config.features.enabled(Feature::ResponsesWebsocketsV2), + ws_version_from_features(config.as_ref()), config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index a0ad85a421..e40905e314 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -157,6 +157,8 @@ pub use zsh_exec_bridge::maybe_run_zsh_exec_wrapper_mode; pub use client::ModelClient; pub use client::ModelClientSession; +pub use client::ResponsesWebsocketVersion; +pub use client::ws_version_from_features; pub use client_common::Prompt; pub use client_common::REVIEW_PROMPT; pub use client_common::ResponseEvent; diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index b392081eeb..126dc2c288 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -6,6 +6,7 @@ use codex_core::ModelClient; use codex_core::ModelProviderInfo; use codex_core::Prompt; use codex_core::ResponseEvent; +use codex_core::ResponsesWebsocketVersion; use codex_core::WireApi; use codex_otel::OtelManager; use codex_otel::TelemetryAuthMode; @@ -91,8 +92,7 @@ async fn responses_stream_includes_subagent_header_on_review() { provider.clone(), session_source, config.model_verbosity, - false, - false, + None::, false, false, None, @@ -197,8 +197,7 @@ async fn responses_stream_includes_subagent_header_on_other() { provider.clone(), session_source, config.model_verbosity, - false, - false, + None::, false, false, None, @@ -302,8 +301,7 @@ async fn responses_respects_model_info_overrides_from_config() { provider.clone(), session_source, config.model_verbosity, - false, - false, + None::, false, false, None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 77a2ba33e0..5fc94a033e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -4,6 +4,7 @@ use codex_core::ModelProviderInfo; use codex_core::NewThread; use codex_core::Prompt; use codex_core::ResponseEvent; +use codex_core::ResponsesWebsocketVersion; use codex_core::ThreadManager; use codex_core::WireApi; use codex_core::auth::AuthCredentialsStoreMode; @@ -1353,8 +1354,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { provider.clone(), SessionSource::Exec, config.model_verbosity, - false, - false, + None::, false, false, None, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 54e197e568..d8b1940d15 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -8,6 +8,7 @@ use codex_core::ResponseEvent; use codex_core::WireApi; use codex_core::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; use codex_core::features::Feature; +use codex_core::ws_version_from_features; use codex_otel::OtelManager; use codex_otel::TelemetryAuthMode; use codex_otel::metrics::MetricsClient; @@ -319,6 +320,62 @@ async fn responses_websocket_v2_requests_use_v2_when_model_prefers_websockets() server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_v2_wins_when_both_features_enabled() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "assistant output"), + ev_done_with_id("resp-1"), + ], + vec![ev_response_created("resp-2"), ev_completed("resp-2")], + ]]) + .await; + + let harness = websocket_harness_with_options(&server, false, true, true, false).await; + let mut client_session = harness.client.new_session(); + let prompt_one = prompt_with_input(vec![message_item("hello")]); + let prompt_two = prompt_with_input(vec![ + message_item("hello"), + assistant_message_item("msg-1", "assistant output"), + message_item("second"), + ]); + + stream_until_complete(&mut client_session, &harness, &prompt_one).await; + stream_until_complete(&mut client_session, &harness, &prompt_two).await; + + let connection = server.single_connection(); + assert_eq!(connection.len(), 2); + let second = connection.get(1).expect("missing request").body_json(); + assert_eq!(second["type"].as_str(), Some("response.create")); + assert_eq!(second["previous_response_id"].as_str(), Some("resp-1")); + assert_eq!( + second["input"], + serde_json::to_value(&prompt_two.input[2..]).unwrap() + ); + + let handshake = server.single_handshake(); + let openai_beta_header = handshake + .header(OPENAI_BETA_HEADER) + .expect("missing OpenAI-Beta header"); + assert!( + openai_beta_header + .split(',') + .map(str::trim) + .any(|value| value == WS_V2_BETA_HEADER_VALUE) + ); + assert!( + !openai_beta_header + .split(',') + .map(str::trim) + .any(|value| value == OPENAI_BETA_RESPONSES_WEBSOCKETS) + ); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[traced_test] async fn responses_websocket_emits_websocket_telemetry_events() { @@ -1251,8 +1308,7 @@ async fn websocket_harness_with_options( provider.clone(), SessionSource::Exec, config.model_verbosity, - websocket_enabled, - websocket_v2_enabled, + ws_version_from_features(&config), false, runtime_metrics_enabled, None, From 3586fcb8020a3a3bd377582c09de275fa4c3d312 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 21 Feb 2026 14:25:36 -0800 Subject: [PATCH 041/282] Improve token usage estimate for images (#12419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #11845. Adjust context/token estimation for inline image `data:*;base64,...` URLs so we do not count the raw base64 payload as model-visible text. What changed: - keep the existing JSON-length estimator as the baseline - detect only inline base64 `data:` image URLs in message and function-call output content items - subtract only the base64 payload bytes (preserving data URL prefix + JSON overhead) - add a fixed per-image estimate of 340 bytes (~85 tokens at the repo’s 4-bytes/token heuristic) This avoids large overestimates from MCP image tool outputs while leaving normal image URLs (`https://`, `file://`, non-base64 `data:` URLs) unchanged. Tests: - message image data URL estimate regression - function-call output image data URL estimate regression - non-base64 image URLs unchanged - non-base64 `data:` URLs unchanged - `data:application/octet-stream;base64,...` adjusted - multiple inline images apply multiple fixed costs - text-only items unchanged --- codex-rs/core/src/context_manager/history.rs | 97 ++++++++- .../core/src/context_manager/history_tests.rs | 192 ++++++++++++++++++ 2 files changed, 286 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 52df99fb02..a3dbc643bb 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -419,6 +419,12 @@ fn estimate_item_token_count(item: &ResponseItem) -> i64 { approx_tokens_from_byte_count_i64(model_visible_bytes) } +/// Approximate model-visible byte cost for one image input. +/// +/// The estimator later converts bytes to tokens using a 4-bytes/token heuristic, +/// so 340 bytes is approximately 85 tokens. +const IMAGE_BYTES_ESTIMATE: i64 = 340; + pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> i64 { match item { ResponseItem::GhostSnapshot { .. } => 0, @@ -429,12 +435,97 @@ pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> | ResponseItem::Compaction { encrypted_content: content, } => i64::try_from(estimate_reasoning_length(content.len())).unwrap_or(i64::MAX), - item => serde_json::to_string(item) - .map(|serialized| i64::try_from(serialized.len()).unwrap_or(i64::MAX)) - .unwrap_or_default(), + item => { + let raw = serde_json::to_string(item) + .map(|serialized| i64::try_from(serialized.len()).unwrap_or(i64::MAX)) + .unwrap_or_default(); + let (payload_bytes, image_count) = image_data_url_estimate_adjustment(item); + if payload_bytes == 0 || image_count == 0 { + raw + } else { + // Replace raw base64 payload bytes with a fixed per-image cost. + // We intentionally preserve the data URL prefix and JSON wrapper + // bytes already included in `raw`. + raw.saturating_sub(payload_bytes) + .saturating_add(image_count.saturating_mul(IMAGE_BYTES_ESTIMATE)) + } + } } } +/// Returns the base64 payload byte length for inline image data URLs that are +/// eligible for token-estimation discounting. +/// +/// We only discount payloads for `data:image/...;base64,...` URLs (case +/// insensitive markers) and leave everything else at raw serialized size. +fn base64_data_url_payload_len(url: &str) -> Option { + if !url + .get(.."data:".len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:")) + { + return None; + } + let comma_index = url.find(',')?; + let metadata = &url[..comma_index]; + let payload = &url[comma_index + 1..]; + // Parse the media type and parameters without decoding. This keeps the + // estimator cheap while ensuring we only apply the fixed-cost image + // heuristic to image-typed base64 data URLs. + let metadata_without_scheme = &metadata["data:".len()..]; + let mut metadata_parts = metadata_without_scheme.split(';'); + let mime_type = metadata_parts.next().unwrap_or_default(); + let has_base64_marker = metadata_parts.any(|part| part.eq_ignore_ascii_case("base64")); + if !mime_type + .get(.."image/".len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("image/")) + { + return None; + } + if !has_base64_marker { + return None; + } + Some(payload.len()) +} + +/// Scans one response item for discount-eligible inline image data URLs and +/// returns: +/// - total base64 payload bytes to subtract from raw serialized size +/// - count of qualifying images to replace with `IMAGE_BYTES_ESTIMATE` +fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) { + let mut payload_bytes = 0i64; + let mut image_count = 0i64; + + let mut accumulate = |image_url: &str| { + if let Some(payload_len) = base64_data_url_payload_len(image_url) { + payload_bytes = + payload_bytes.saturating_add(i64::try_from(payload_len).unwrap_or(i64::MAX)); + image_count = image_count.saturating_add(1); + } + }; + + match item { + ResponseItem::Message { content, .. } => { + for content_item in content { + if let ContentItem::InputImage { image_url } = content_item { + accumulate(image_url); + } + } + } + ResponseItem::FunctionCallOutput { output, .. } => { + if let FunctionCallOutputBody::ContentItems(items) = &output.body { + for content_item in items { + if let FunctionCallOutputContentItem::InputImage { image_url } = content_item { + accumulate(image_url); + } + } + } + } + _ => {} + } + + (payload_bytes, image_count) +} + fn is_model_generated_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } => role == "assistant", diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index dbecaa9efc..3e11dae40a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1240,3 +1240,195 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); } + +#[test] +fn image_data_url_payload_does_not_dominate_message_estimate() { + let payload = "A".repeat(100_000); + let image_url = format!("data:image/png;base64,{payload}"); + let image_item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "Here is the screenshot".to_string(), + }, + ContentItem::InputImage { image_url }, + ], + end_turn: None, + phase: None, + }; + let text_only_item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "Here is the screenshot".to_string(), + }], + end_turn: None, + phase: None, + }; + + let raw_len = serde_json::to_string(&image_item).unwrap().len() as i64; + let estimated = estimate_response_item_model_visible_bytes(&image_item); + let expected = raw_len - payload.len() as i64 + IMAGE_BYTES_ESTIMATE; + let text_only_estimated = estimate_response_item_model_visible_bytes(&text_only_item); + + assert_eq!(estimated, expected); + assert!(estimated < raw_len); + assert!(estimated > text_only_estimated); +} + +#[test] +fn image_data_url_payload_does_not_dominate_function_call_output_estimate() { + let payload = "B".repeat(50_000); + let image_url = format!("data:image/png;base64,{payload}"); + let item = ResponseItem::FunctionCallOutput { + call_id: "call-abc".to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputText { + text: "Screenshot captured".to_string(), + }, + FunctionCallOutputContentItem::InputImage { image_url }, + ]), + }; + + let raw_len = serde_json::to_string(&item).unwrap().len() as i64; + let estimated = estimate_response_item_model_visible_bytes(&item); + let expected = raw_len - payload.len() as i64 + IMAGE_BYTES_ESTIMATE; + + assert_eq!(estimated, expected); + assert!(estimated < raw_len); +} + +#[test] +fn non_base64_image_urls_are_unchanged() { + let message_item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { + image_url: "https://example.com/foo.png".to_string(), + }], + end_turn: None, + phase: None, + }; + let function_output_item = ResponseItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputImage { + image_url: "file:///tmp/foo.png".to_string(), + }, + ]), + }; + + assert_eq!( + estimate_response_item_model_visible_bytes(&message_item), + serde_json::to_string(&message_item).unwrap().len() as i64 + ); + assert_eq!( + estimate_response_item_model_visible_bytes(&function_output_item), + serde_json::to_string(&function_output_item).unwrap().len() as i64 + ); +} + +#[test] +fn data_url_without_base64_marker_is_unchanged() { + let item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { + image_url: "data:image/svg+xml,".to_string(), + }], + end_turn: None, + phase: None, + }; + + assert_eq!( + estimate_response_item_model_visible_bytes(&item), + serde_json::to_string(&item).unwrap().len() as i64 + ); +} + +#[test] +fn non_image_base64_data_url_is_unchanged() { + let payload = "C".repeat(4_096); + let image_url = format!("data:application/octet-stream;base64,{payload}"); + let item = ResponseItem::FunctionCallOutput { + call_id: "call-octet".to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputImage { image_url }, + ]), + }; + + let raw_len = serde_json::to_string(&item).unwrap().len() as i64; + let estimated = estimate_response_item_model_visible_bytes(&item); + + assert_eq!(estimated, raw_len); +} + +#[test] +fn mixed_case_data_url_markers_are_adjusted() { + let payload = "F".repeat(1_024); + let image_url = format!("DATA:image/png;BASE64,{payload}"); + let item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { image_url }], + end_turn: None, + phase: None, + }; + + let raw_len = serde_json::to_string(&item).unwrap().len() as i64; + let estimated = estimate_response_item_model_visible_bytes(&item); + let expected = raw_len - payload.len() as i64 + IMAGE_BYTES_ESTIMATE; + + assert_eq!(estimated, expected); +} + +#[test] +fn multiple_inline_images_apply_multiple_fixed_costs() { + let payload_one = "D".repeat(100); + let payload_two = "E".repeat(200); + let image_url_one = format!("data:image/png;base64,{payload_one}"); + let image_url_two = format!("data:image/jpeg;base64,{payload_two}"); + let item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "images".to_string(), + }, + ContentItem::InputImage { + image_url: image_url_one, + }, + ContentItem::InputImage { + image_url: image_url_two, + }, + ], + end_turn: None, + phase: None, + }; + + let raw_len = serde_json::to_string(&item).unwrap().len() as i64; + let payload_sum = (payload_one.len() + payload_two.len()) as i64; + let estimated = estimate_response_item_model_visible_bytes(&item); + let expected = raw_len - payload_sum + (2 * IMAGE_BYTES_ESTIMATE); + + assert_eq!(estimated, expected); +} + +#[test] +fn text_only_items_unchanged() { + let item = ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "Hello world, this is a response.".to_string(), + }], + end_turn: None, + phase: None, + }; + + let estimated = estimate_response_item_model_visible_bytes(&item); + let raw_len = serde_json::to_string(&item).unwrap().len() as i64; + + assert_eq!(estimated, raw_len); +} From f33ac830aad5cdee4e52cf7eca0a73686a17b375 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 21 Feb 2026 14:40:13 -0800 Subject: [PATCH 042/282] fix: make skills loader tests hermetic with ~/.agents skills (#12474) --- codex-rs/core/src/skills/loader.rs | 82 ++++++++++++++++++------------ 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 1ad231438a..6508d4fd10 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -137,7 +137,17 @@ impl fmt::Display for SkillParseError { impl Error for SkillParseError {} pub fn load_skills(config: &Config) -> SkillLoadOutcome { - load_skills_from_roots(skill_roots(config)) + load_skills_with_home_dir(config, home_dir().as_deref()) +} + +fn load_skills_with_home_dir(config: &Config, home_dir: Option<&Path>) -> SkillLoadOutcome { + let mut roots = skill_roots_from_layer_stack_inner(&config.config_layer_stack, home_dir); + roots.extend(repo_agents_skill_roots( + &config.config_layer_stack, + &config.cwd, + )); + dedupe_skill_roots_by_path(&mut roots); + load_skills_from_roots(roots) } pub(crate) struct SkillRoot { @@ -240,6 +250,7 @@ fn skill_roots_from_layer_stack_inner( roots } +#[cfg(test)] fn skill_roots(config: &Config) -> Vec { skill_roots_from_layer_stack_with_agents(&config.config_layer_stack, &config.cwd) } @@ -867,6 +878,11 @@ mod tests { .expect("defaults for test should always succeed") } + fn load_skills_for_test(config: &Config) -> SkillLoadOutcome { + // Keep unit tests hermetic by never scanning the real `$HOME/.agents/skills`. + super::load_skills_with_home_dir(config, None) + } + fn mark_as_git_repo(dir: &Path) { // Config/project-root discovery only checks for the presence of `.git` (file or dir), // so we can avoid shelling out to `git init` in tests. @@ -1129,7 +1145,7 @@ mod tests { ); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1208,7 +1224,7 @@ interface: ); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1258,7 +1274,7 @@ policy: ); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1289,7 +1305,7 @@ policy: {} ); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1332,7 +1348,7 @@ permissions: ); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1394,7 +1410,7 @@ permissions: {} ); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1452,7 +1468,7 @@ permissions: ); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1502,7 +1518,7 @@ permissions: ); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1547,7 +1563,7 @@ permissions: ); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1595,7 +1611,7 @@ permissions: ); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1642,7 +1658,7 @@ permissions: ); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1691,7 +1707,7 @@ permissions: ); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1736,7 +1752,7 @@ permissions: symlink_dir(shared.path(), &codex_home.path().join("skills/shared")); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1773,7 +1789,7 @@ permissions: symlink_file(&shared_skill_path, &skill_dir.join(SKILLS_FILENAME)); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1797,7 +1813,7 @@ permissions: let skill_path = write_skill_at(&cycle_dir, "demo", "cycle-skill", "still loads"); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1875,7 +1891,7 @@ permissions: symlink_dir(shared.path(), &repo_skills_root.join("shared")); let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -1972,7 +1988,7 @@ permissions: let skill_path = write_skill(&codex_home, "demo", "demo-skill", "does things\ncarefully"); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -2004,7 +2020,7 @@ permissions: fs::write(&skill_path, contents).unwrap(); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -2038,7 +2054,7 @@ permissions: fs::write(skill_dir.join(SKILLS_FILENAME), contents).unwrap(); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert_eq!(outcome.skills.len(), 0); assert_eq!(outcome.errors.len(), 1); assert!( @@ -2067,7 +2083,7 @@ permissions: fs::write(invalid_dir.join(SKILLS_FILENAME), "---\nname: bad").unwrap(); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert_eq!(outcome.skills.len(), 0); assert_eq!(outcome.errors.len(), 1); assert!( @@ -2085,7 +2101,7 @@ permissions: write_skill(&codex_home, "max-len", "max-len", &max_desc); let cfg = make_config(&codex_home).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -2095,7 +2111,7 @@ permissions: let too_long_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN + 1); write_skill(&codex_home, "too-long", "too-long", &too_long_desc); - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert_eq!(outcome.skills.len(), 1); assert_eq!(outcome.errors.len(), 1); assert!( @@ -2117,7 +2133,7 @@ permissions: let skill_path = write_skill_at(&skills_root, "repo", "repo-skill", "from repo"); let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -2153,7 +2169,7 @@ permissions: ); let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -2206,7 +2222,7 @@ permissions: let cfg = make_config_for_cwd(&codex_home, nested_dir).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -2258,7 +2274,7 @@ permissions: let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -2337,7 +2353,7 @@ permissions: let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -2402,7 +2418,7 @@ permissions: ); let cfg = make_config_for_cwd(&codex_home, nested_dir).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), @@ -2468,7 +2484,7 @@ permissions: let cfg = make_config_for_cwd(&codex_home, repo_dir).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -2497,7 +2513,7 @@ permissions: let cfg = make_config_for_cwd(&codex_home, file_path).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -2538,7 +2554,7 @@ permissions: let cfg = make_config_for_cwd(&codex_home, nested_dir).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -2556,7 +2572,7 @@ permissions: let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await; - let outcome = load_skills(&cfg); + let outcome = load_skills_for_test(&cfg); assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", From 66d5d34e6e14ff4b15c4bcd06bffd922abad809c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 21 Feb 2026 14:40:24 -0800 Subject: [PATCH 043/282] core: preserve constrained approval/sandbox policies in TurnContext (#12473) --- codex-rs/core/src/apply_patch.rs | 4 +- codex-rs/core/src/codex.rs | 58 +++++++++++-------- codex-rs/core/src/context_manager/updates.rs | 8 ++- codex-rs/core/src/mcp/skill_dependencies.rs | 4 +- codex-rs/core/src/mcp_tool_call.rs | 4 +- .../core/src/tools/handlers/apply_patch.rs | 16 ++++- .../core/src/tools/handlers/multi_agents.rs | 11 ++-- codex-rs/core/src/tools/handlers/shell.rs | 16 +++-- .../core/src/tools/handlers/unified_exec.rs | 4 +- codex-rs/core/src/tools/js_repl/mod.rs | 32 +++++++--- codex-rs/core/src/tools/network_approval.rs | 2 +- codex-rs/core/src/unified_exec/mod.rs | 8 ++- .../core/src/unified_exec/process_manager.rs | 6 +- 13 files changed, 112 insertions(+), 61 deletions(-) diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 714d9c40fe..0b9cca1c9d 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -38,8 +38,8 @@ pub(crate) async fn apply_patch( ) -> InternalApplyPatchInvocation { match assess_patch_safety( &action, - turn_context.approval_policy, - &turn_context.sandbox_policy, + turn_context.approval_policy.value(), + turn_context.sandbox_policy.get(), &turn_context.cwd, turn_context.windows_sandbox_level, ) { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 55a8062187..832592d406 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -558,8 +558,8 @@ pub(crate) struct TurnContext { pub(crate) user_instructions: Option, pub(crate) collaboration_mode: CollaborationMode, pub(crate) personality: Option, - pub(crate) approval_policy: AskForApproval, - pub(crate) sandbox_policy: SandboxPolicy, + pub(crate) approval_policy: Constrained, + pub(crate) sandbox_policy: Constrained, pub(crate) network: Option, pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, @@ -640,7 +640,7 @@ impl TurnContext { user_instructions: self.user_instructions.clone(), collaboration_mode, personality: self.personality, - approval_policy: self.approval_policy, + approval_policy: self.approval_policy.clone(), sandbox_policy: self.sandbox_policy.clone(), network: self.network.clone(), windows_sandbox_level: self.windows_sandbox_level, @@ -674,8 +674,8 @@ impl TurnContext { TurnContextItem { turn_id: Some(self.sub_id.clone()), cwd: self.cwd.clone(), - approval_policy: self.approval_policy, - sandbox_policy: self.sandbox_policy.clone(), + approval_policy: self.approval_policy.value(), + sandbox_policy: self.sandbox_policy.get().clone(), network: self.turn_context_network_item(), model: self.model_info.slug.clone(), personality: self.personality, @@ -980,8 +980,8 @@ impl Session { user_instructions: session_configuration.user_instructions.clone(), collaboration_mode: session_configuration.collaboration_mode.clone(), personality: session_configuration.personality, - approval_policy: session_configuration.approval_policy.value(), - sandbox_policy: session_configuration.sandbox_policy.get().clone(), + approval_policy: session_configuration.approval_policy.clone(), + sandbox_policy: session_configuration.sandbox_policy.clone(), network, windows_sandbox_level: session_configuration.windows_sandbox_level, shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(), @@ -2713,8 +2713,8 @@ impl Session { let shell = self.user_shell(); items.push( DeveloperInstructions::from_policy( - &turn_context.sandbox_policy, - turn_context.approval_policy, + turn_context.sandbox_policy.get(), + turn_context.approval_policy.value(), self.services.exec_policy.current().as_ref(), &turn_context.cwd, ) @@ -3227,7 +3227,7 @@ impl Session { ); let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode).await; let sandbox_state = SandboxState { - sandbox_policy: turn_context.sandbox_policy.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), sandbox_cwd: turn_context.cwd.clone(), use_linux_sandbox_bwrap: turn_context.features.enabled(Feature::UseLinuxSandboxBwrap), @@ -4340,7 +4340,7 @@ async fn spawn_review_thread( let turn_metadata_state = Arc::new(TurnMetadataState::new( review_turn_id.clone(), parent_turn_context.cwd.clone(), - &parent_turn_context.sandbox_policy, + parent_turn_context.sandbox_policy.get(), parent_turn_context.windows_sandbox_level, parent_turn_context .features @@ -4365,7 +4365,7 @@ async fn spawn_review_thread( compact_prompt: parent_turn_context.compact_prompt.clone(), collaboration_mode: parent_turn_context.collaboration_mode.clone(), personality: parent_turn_context.personality, - approval_policy: parent_turn_context.approval_policy, + approval_policy: parent_turn_context.approval_policy.clone(), sandbox_policy: parent_turn_context.sandbox_policy.clone(), network: parent_turn_context.network.clone(), windows_sandbox_level: parent_turn_context.windows_sandbox_level, @@ -5720,8 +5720,8 @@ async fn try_run_sampling_request( feedback_tags!( model = turn_context.model_info.slug.clone(), - approval_policy = turn_context.approval_policy, - sandbox_policy = turn_context.sandbox_policy, + approval_policy = turn_context.approval_policy.value(), + sandbox_policy = turn_context.sandbox_policy.get(), effort = turn_context.reasoning_effort, auth_mode = sess.services.auth_manager.auth_mode(), features = sess.features.enabled_features(), @@ -6652,8 +6652,8 @@ mod tests { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), cwd: turn_context.cwd.clone(), - approval_policy: turn_context.approval_policy, - sandbox_policy: turn_context.sandbox_policy.clone(), + approval_policy: turn_context.approval_policy.value(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), personality: turn_context.personality, @@ -6689,8 +6689,8 @@ mod tests { let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), cwd: turn_context.cwd.clone(), - approval_policy: turn_context.approval_policy, - sandbox_policy: turn_context.sandbox_policy.clone(), + approval_policy: turn_context.approval_policy.value(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), personality: turn_context.personality, @@ -7051,8 +7051,8 @@ mod tests { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), cwd: turn_context.cwd.clone(), - approval_policy: turn_context.approval_policy, - sandbox_policy: turn_context.sandbox_policy.clone(), + approval_policy: turn_context.approval_policy.value(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), personality: turn_context.personality, @@ -8809,7 +8809,10 @@ mod tests { let (session, mut turn_context_raw) = make_session_and_context().await; // Ensure policy is NOT OnRequest so the early rejection path triggers - turn_context_raw.approval_policy = AskForApproval::OnFailure; + turn_context_raw + .approval_policy + .set(AskForApproval::OnFailure) + .expect("test setup should allow updating approval policy"); let session = Arc::new(session); let mut turn_context = Arc::new(turn_context_raw); @@ -8883,7 +8886,7 @@ mod tests { let expected = format!( "approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}", - policy = turn_context.approval_policy + policy = turn_context.approval_policy.value() ); pretty_assertions::assert_eq!(output, expected); @@ -8892,7 +8895,9 @@ mod tests { // Force DangerFullAccess to avoid platform sandbox dependencies in tests. Arc::get_mut(&mut turn_context) .expect("unique turn context Arc") - .sandbox_policy = SandboxPolicy::DangerFullAccess; + .sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); let resp2 = handler .handle(ToolInvocation { @@ -8946,7 +8951,10 @@ mod tests { use crate::turn_diff_tracker::TurnDiffTracker; let (session, mut turn_context_raw) = make_session_and_context().await; - turn_context_raw.approval_policy = AskForApproval::OnFailure; + turn_context_raw + .approval_policy + .set(AskForApproval::OnFailure) + .expect("test setup should allow updating approval policy"); let session = Arc::new(session); let turn_context = Arc::new(turn_context_raw); let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); @@ -8976,7 +8984,7 @@ mod tests { let expected = format!( "approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}", - policy = turn_context.approval_policy + policy = turn_context.approval_policy.value() ); pretty_assertions::assert_eq!(output, expected); diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index cdb53ff0c4..87a31031b3 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -31,14 +31,16 @@ fn build_permissions_update_item( exec_policy: &Policy, ) -> Option { let prev = previous?; - if prev.sandbox_policy == next.sandbox_policy && prev.approval_policy == next.approval_policy { + if prev.sandbox_policy == *next.sandbox_policy.get() + && prev.approval_policy == next.approval_policy.value() + { return None; } Some( DeveloperInstructions::from_policy( - &next.sandbox_policy, - next.approval_policy, + next.sandbox_policy.get(), + next.approval_policy.value(), exec_policy, &next.cwd, ) diff --git a/codex-rs/core/src/mcp/skill_dependencies.rs b/codex-rs/core/src/mcp/skill_dependencies.rs index e8b2d4f7b0..f2138cc293 100644 --- a/codex-rs/core/src/mcp/skill_dependencies.rs +++ b/codex-rs/core/src/mcp/skill_dependencies.rs @@ -32,9 +32,9 @@ const MCP_DEPENDENCY_OPTION_INSTALL: &str = "Install"; const MCP_DEPENDENCY_OPTION_SKIP: &str = "Continue anyway"; fn is_full_access_mode(turn_context: &TurnContext) -> bool { - matches!(turn_context.approval_policy, AskForApproval::Never) + matches!(turn_context.approval_policy.value(), AskForApproval::Never) && matches!( - turn_context.sandbox_policy, + turn_context.sandbox_policy.get(), SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } ) } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index f194ee79a6..ce260773d2 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -401,9 +401,9 @@ async fn maybe_request_mcp_tool_approval( } fn is_full_access_mode(turn_context: &TurnContext) -> bool { - matches!(turn_context.approval_policy, AskForApproval::Never) + matches!(turn_context.approval_policy.value(), AskForApproval::Never) && matches!( - turn_context.sandbox_policy, + turn_context.sandbox_policy.get(), SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } ) } diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 8f14fccedf..3ef79f7aa0 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -145,7 +145,13 @@ impl ToolHandler for ApplyPatchHandler { tool_name: tool_name.to_string(), }; let out = orchestrator - .run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy) + .run( + &mut runtime, + &req, + &tool_ctx, + &turn, + turn.approval_policy.value(), + ) .await .map(|result| result.output); let event_ctx = ToolEventCtx::new( @@ -235,7 +241,13 @@ pub(crate) async fn intercept_apply_patch( tool_name: tool_name.to_string(), }; let out = orchestrator - .run(&mut runtime, &req, &tool_ctx, turn, turn.approval_policy) + .run( + &mut runtime, + &req, + &tool_ctx, + turn, + turn.approval_policy.value(), + ) .await .map(|result| result.output); let event_ctx = diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index b0c4cc4126..455a209526 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -919,7 +919,7 @@ fn build_agent_shared_config( config .permissions .sandbox_policy - .set(turn.sandbox_policy.clone()) + .set(turn.sandbox_policy.get().clone()) .map_err(|err| { FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) })?; @@ -1913,10 +1913,13 @@ mod tests { let temp_dir = tempfile::tempdir().expect("temp dir"); turn.cwd = temp_dir.path().to_path_buf(); turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo")); - turn.sandbox_policy = pick_allowed_sandbox_policy( + let sandbox_policy = pick_allowed_sandbox_policy( &turn.config.permissions.sandbox_policy, turn.config.permissions.sandbox_policy.get().clone(), ); + turn.sandbox_policy + .set(sandbox_policy) + .expect("sandbox policy set"); let config = build_agent_spawn_config(&base_instructions, &turn, 0).expect("spawn config"); let mut expected = (*turn.config).clone(); @@ -1938,7 +1941,7 @@ mod tests { expected .permissions .sandbox_policy - .set(turn.sandbox_policy) + .set(turn.sandbox_policy.get().clone()) .expect("sandbox policy set"); assert_eq!(config, expected); } @@ -1987,7 +1990,7 @@ mod tests { expected .permissions .sandbox_policy - .set(turn.sandbox_policy) + .set(turn.sandbox_policy.get().clone()) .expect("sandbox policy set"); assert_eq!(config, expected); } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index f040b4ba64..4cdbf454c4 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -281,11 +281,11 @@ impl ShellHandler { .sandbox_permissions .requires_escalated_permissions() && !matches!( - turn.approval_policy, + turn.approval_policy.value(), codex_protocol::protocol::AskForApproval::OnRequest ) { - let approval_policy = turn.approval_policy; + let approval_policy = turn.approval_policy.value(); return Err(FunctionCallError::RespondToModel(format!( "approval policy is {approval_policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {approval_policy:?}" ))); @@ -322,8 +322,8 @@ impl ShellHandler { .exec_policy .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &exec_params.command, - approval_policy: turn.approval_policy, - sandbox_policy: &turn.sandbox_policy, + approval_policy: turn.approval_policy.value(), + sandbox_policy: turn.sandbox_policy.get(), sandbox_permissions: exec_params.sandbox_permissions, prefix_rule, }) @@ -349,7 +349,13 @@ impl ShellHandler { tool_name, }; let out = orchestrator - .run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy) + .run( + &mut runtime, + &req, + &tool_ctx, + &turn, + turn.approval_policy.value(), + ) .await .map(|result| result.output); let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None); diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 62da64a877..c7d57731e3 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -152,11 +152,11 @@ impl ToolHandler for UnifiedExecHandler { if sandbox_permissions.requires_escalated_permissions() && !matches!( - context.turn.approval_policy, + context.turn.approval_policy.value(), codex_protocol::protocol::AskForApproval::OnRequest ) { - let approval_policy = context.turn.approval_policy; + let approval_policy = context.turn.approval_policy.value(); manager.release_process_id(&process_id).await; return Err(FunctionCallError::RespondToModel(format!( "approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}" diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 5b7b539bb3..79195d97f6 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -1800,8 +1800,12 @@ mod tests { } let (session, mut turn) = make_session_and_context().await; - turn.approval_policy = AskForApproval::Never; - turn.sandbox_policy = SandboxPolicy::DangerFullAccess; + turn.approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + turn.sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1843,8 +1847,12 @@ mod tests { } let (session, mut turn) = make_session_and_context().await; - turn.approval_policy = AskForApproval::Never; - turn.sandbox_policy = SandboxPolicy::DangerFullAccess; + turn.approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + turn.sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1891,8 +1899,12 @@ try { } let (session, mut turn) = make_session_and_context().await; - turn.approval_policy = AskForApproval::Never; - turn.sandbox_policy = SandboxPolicy::DangerFullAccess; + turn.approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + turn.sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1941,8 +1953,12 @@ console.log("cell-complete"); { return Ok(()); } - turn.approval_policy = AskForApproval::Never; - turn.sandbox_policy = SandboxPolicy::DangerFullAccess; + turn.approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + turn.sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); let session = Arc::new(session); let turn = Arc::new(turn); diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 4a893f74e9..0d4f2f3078 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -299,7 +299,7 @@ impl NetworkApprovalService { .await; return NetworkDecision::deny(REASON_NOT_ALLOWED); }; - if !allows_network_prompt(turn_context.approval_policy) { + if !allows_network_prompt(turn_context.approval_policy.value()) { pending.set_decision(PendingApprovalDecision::Deny).await; let mut pending_approvals = self.pending_host_approvals.lock().await; pending_approvals.remove(&key); diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index a586572dd1..62269a96d6 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -193,8 +193,12 @@ mod tests { async fn test_session_and_turn() -> (Arc, Arc) { let (session, mut turn) = make_session_and_context().await; - turn.approval_policy = AskForApproval::Never; - turn.sandbox_policy = SandboxPolicy::DangerFullAccess; + turn.approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + turn.sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); (Arc::new(session), Arc::new(turn)) } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 6945316469..68c4f95ceb 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -576,8 +576,8 @@ impl UnifiedExecProcessManager { .exec_policy .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &request.command, - approval_policy: context.turn.approval_policy, - sandbox_policy: &context.turn.sandbox_policy, + approval_policy: context.turn.approval_policy.value(), + sandbox_policy: context.turn.sandbox_policy.get(), sandbox_permissions: request.sandbox_permissions, prefix_rule: request.prefix_rule.clone(), }) @@ -605,7 +605,7 @@ impl UnifiedExecProcessManager { &req, &tool_ctx, context.turn.as_ref(), - context.turn.approval_policy, + context.turn.approval_policy.value(), ) .await .map(|result| (result.output, result.deferred_network_approval)) From 2ba2c57af4b548512a749a6947341452a16b3521 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 21 Feb 2026 20:31:41 -0300 Subject: [PATCH 044/282] fix(tui): preserve URL clickability across all TUI views (#12067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Long URLs containing `/` and `-` characters are split across multiple terminal lines by `textwrap`'s default hyphenation rules. This breaks terminal link detection: emulators can no longer identify the URL as clickable, and copy-paste yields a truncated fragment. The issue affects every view that renders user or agent text — exec output, history cells, markdown, the app-link setup screen, and the VT100 scrollback path. A secondary bug compounds the first: `desired_height()` calculations count logical lines rather than viewport rows. When a URL overflows its line and wraps visually, the height budget is too small, causing content to clip or leave gaps. Here is how the complete URL is interpreted by the terminal before (first line only) and after (complete URL): | Before | After | |---|---| | Screenshot 2026-02-17 at 7 59 11
PM | Screenshot 2026-02-17 at 7 58
40 PM | ## Mental model The TUI now treats URL-like tokens as atomic units that must never be split by the wrapping engine. Every call site that previously used `word_wrap_*` has been migrated to `adaptive_wrap_*`, which inspects each line for URL-like tokens and switches wrapping strategy accordingly: - **Non-URL lines** follow the existing `textwrap` path unchanged (word boundaries, optional indentation, hyphenation). - **URL-only lines** (with at most decorative markers like `│`, `-`, `1.`) are emitted unwrapped so terminal link detection works; ratatui's `Wrap { trim: false }` handles the final character wrap at render time. - **Mixed lines** (URL + substantive non-URL prose) flow through `adaptive_wrap_line` so prose wraps naturally at word boundaries while URL tokens remain unsplit. Height measurement everywhere now delegates to `Paragraph::line_count(width)`, which accounts for the visual row cost of overflowed lines. This single source of truth replaces ad-hoc line counting in individual cells. For terminal scrollback (the VT100 path that prints history when the TUI exits), URL-only lines are emitted unwrapped so the terminal's own link detector can find them. Mixed URL+prose lines use adaptive wrapping so surrounding text wraps naturally. Continuation rows are pre-cleared to avoid stale content artifacts. ## Non-goals - Full RFC 3986 URL parsing. The detector is a conservative heuristic that covers `scheme://host`, bare domains (`example.com/path`), `localhost:port`, and IPv4 hosts. IPv6 (`[::1]:8080`) and exotic schemes are intentionally excluded from v1. - Changing wrapping behavior for non-URL content. - Reflowing or reformatting existing terminal scrollback on resize. ## Tradeoffs | Decision | Upside | Downside | |----------|--------|----------| | Heuristic URL detection vs. full parser | Fast, zero-alloc on the hot path; conservative enough to reject file paths like `src/main.rs` | False negatives on obscure URL formats (they get split as before) | | Adaptive (three-path) wrapping | Non-URL lines are untouched — no behavior change, no perf cost; mixed lines wrap prose naturally while preserving URLs | Three wrapping strategies to reason about when debugging layout | | Row-based truncation with line-unit ellipsis | Accurate viewport budget; stable "N lines omitted" count across terminal widths | `truncate_lines_middle` is more complex (must compute per-line row cost) | | Unwrapped URL-only lines in scrollback | Terminal emulators detect clickable links; copy-paste gets the full URL | TUI and scrollback formatting diverge for URL-only lines | | Default `desired_height` via `Paragraph::line_count` | DRY — most cells inherit correct measurement | Cells with custom layout must remember to override | ## Architecture ```mermaid flowchart TD A["adaptive_wrap_*()"] --> B{"line_contains_url_like?"} B -- No URL tokens --> C["word_wrap_line
(textwrap default)"] B -- Has URL tokens --> D{"mixed URL + prose?"} D -- "URL-only
(+ decorative markers)" --> E["emit unwrapped
(terminal char-wraps)"] D -- "Mixed
(URL + substantive text)" --> F["adaptive_wrap_line
(AsciiSpace + custom WordSplitter)"] C --> G["Paragraph::line_count(w)
(single height truth)"] E --> G F --> G ``` **Changed files:** | File | Role | |------|------| | `wrapping.rs` | URL detection heuristics, mixed-line detection, `adaptive_wrap_*` functions, custom `WordSplitter` | | `exec_cell/render.rs` | Row-aware `truncate_lines_middle`, adaptive wrapping for command/output display | | `history_cell.rs` | Migrate all cell types to `adaptive_wrap_*`; default `desired_height` via `Paragraph::line_count` | | `insert_history.rs` | Three-path scrollback wrapping (unwrapped URL-only, adaptive mixed, word-wrapped text); continuation row clearing | | `app_link_view.rs` | Adaptive wrapping for setup URL; `desired_height` via `Paragraph::line_count` | | `markdown_render.rs` | Adaptive wrapping in `finish_paragraph` | | `model_migration.rs` | Viewport-aware wrapping for narrow-pane markdown | | `pager_overlay.rs` | `Wrap { trim: false }` for transcript and streaming chunks | | `queued_user_messages.rs` | Migrate to `adaptive_wrap_lines` | | `status/card.rs` | Migrate to `adaptive_wrap_lines` | ## Observability - **Ellipsis message** in truncated exec output reports omitted count in logical lines (stable across resize) rather than viewport rows (fluctuates). - URL detection is deterministic and stateless — no hidden caching or memoization to go stale. - Height mismatch bugs surface immediately as visual clipping or gaps; the `Paragraph::line_count` path is the same code ratatui uses at render time, so measurement and rendering cannot diverge. ## Tests 26 new unit tests across 7 files, covering: - **URL integrity**: assert a URL-like token appears on exactly one rendered line (not split across two). - **Height accuracy**: compare `desired_height()` against `Paragraph::line_count()` for URL-containing content. - **Row-aware truncation**: verify ellipsis counts logical lines and output fits within the row budget. - **Scrollback rendering**: VT100 backend tests confirm prefix and URL land on the same row; continuation rows are cleared; mixed URL+prose lines wrap prose while preserving URL tokens. - **Mixed URL+prose detection**: `line_has_mixed_url_and_non_url_tokens` correctly distinguishes lines with substantive non-URL text from lines with only decorative markers alongside a URL. - **Heuristic correctness**: positive matches (`https://...`, `example.com/path`, `localhost:3000/api`, `192.168.1.1:8080/health`) and negative matches (`src/main.rs`, `foo/bar`, `hello-world`). ## Risks and open items 1. **URL-like tokens in code output** (e.g. `example.com/api` inside a JSON blob) will trigger URL-preserving wrap on that line. This is acceptable — the worst case is a slightly wider line, not broken output. 2. **Very long non-URL tokens on a URL line** can only break at character boundaries (the custom splitter emits all char indices for non-URL words). On extremely narrow terminals this could overflow, but narrow terminals already degrade gracefully. 3. **No IPv6 support** — `[::1]:8080/path` will be treated as a non-URL and may get split. Can be added later without API changes. Fixes #5457 --- codex-rs/tui/src/bottom_pane/app_link_view.rs | 108 +++- .../src/bottom_pane/queued_user_messages.rs | 36 +- codex-rs/tui/src/custom_terminal.rs | 44 +- codex-rs/tui/src/exec_cell/render.rs | 385 +++++++++-- codex-rs/tui/src/history_cell.rs | 394 ++++++++--- codex-rs/tui/src/insert_history.rs | 213 +++++- codex-rs/tui/src/markdown_render.rs | 18 +- codex-rs/tui/src/model_migration.rs | 37 +- codex-rs/tui/src/onboarding/auth.rs | 142 +++- .../onboarding/auth/headless_chatgpt_login.rs | 14 +- codex-rs/tui/src/pager_overlay.rs | 7 +- ...pdate_with_note_and_wrapping_snapshot.snap | 4 +- codex-rs/tui/src/status/card.rs | 4 +- codex-rs/tui/src/wrapping.rs | 610 +++++++++++++++++- 14 files changed, 1839 insertions(+), 177 deletions(-) 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 698825d6f7..2e6fab3d24 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -10,6 +10,7 @@ use ratatui::text::Line; use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use ratatui::widgets::Wrap; use textwrap::wrap; use super::CancellationEvent; @@ -24,7 +25,8 @@ use crate::key_hint; use crate::render::Insets; use crate::render::RectExt as _; use crate::style::user_message_style; -use crate::wrapping::word_wrap_lines; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_lines; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AppLinkScreen { @@ -234,7 +236,10 @@ impl AppLinkView { lines.push(Line::from("")); lines.push(Line::from(vec!["Setup URL:".dim()])); let url_line = Line::from(vec![self.url.clone().cyan().underlined()]); - lines.extend(word_wrap_lines(vec![url_line], usable_width)); + lines.extend(adaptive_wrap_lines( + vec![url_line], + RtOptions::new(usable_width), + )); lines } @@ -374,8 +379,12 @@ impl crate::render::renderable::Renderable for AppLinkView { fn desired_height(&self, width: u16) -> u16 { let content_width = width.saturating_sub(4).max(1); let content_lines = self.content_lines(content_width); + let content_rows = Paragraph::new(content_lines) + .wrap(Wrap { trim: false }) + .line_count(content_width) + .max(1) as u16; let action_rows_height = self.action_rows_height(content_width); - content_lines.len() as u16 + action_rows_height + 3 + content_rows + action_rows_height + 3 } fn render(&self, area: Rect, buf: &mut Buffer) { @@ -398,7 +407,9 @@ impl crate::render::renderable::Renderable for AppLinkView { let inner = content_area.inset(Insets::vh(1, 2)); let content_width = inner.width.max(1); let lines = self.content_lines(content_width); - Paragraph::new(lines).render(inner, buf); + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(inner, buf); if actions_area.height > 0 { let actions_area = Rect { @@ -435,6 +446,7 @@ impl crate::render::renderable::Renderable for AppLinkView { mod tests { use super::*; use crate::app_event::AppEvent; + use crate::render::renderable::Renderable; use tokio::sync::mpsc::unbounded_channel; #[test] @@ -493,4 +505,92 @@ mod tests { vec!["Manage on ChatGPT", "Enable app", "Back"] ); } + + #[test] + fn install_confirmation_does_not_split_long_url_like_token_without_scheme() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let url_like = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: url_like.to_string(), + is_installed: true, + is_enabled: true, + }, + tx, + ); + view.screen = AppLinkScreen::InstallConfirmation; + + let rendered: Vec = view + .content_lines(40) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::() + }) + .collect(); + + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(url_like)) + .count(), + 1, + "expected full URL-like token in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn install_confirmation_render_keeps_url_tail_visible_when_narrow() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/tail42"; + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + }, + tx, + ); + view.screen = AppLinkScreen::InstallConfirmation; + + let width: u16 = 36; + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let rendered_blob = (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| { + let symbol = buf[(x, y)].symbol(); + if symbol.is_empty() { + ' ' + } else { + symbol.chars().next().unwrap_or(' ') + } + }) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + rendered_blob.contains("tail42"), + "expected wrapped setup URL tail to remain visible in narrow pane, got:\n{rendered_blob}" + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs index 010b88111c..30a525f457 100644 --- a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs +++ b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs @@ -8,7 +8,7 @@ use ratatui::widgets::Paragraph; use crate::key_hint; use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_lines; +use crate::wrapping::adaptive_wrap_lines; /// Widget that displays a list of user messages queued while a turn is in progress. /// @@ -46,7 +46,7 @@ impl QueuedUserMessages { let mut lines = vec![]; for message in &self.messages { - let wrapped = word_wrap_lines( + let wrapped = adaptive_wrap_lines( message.lines().map(|line| line.dim().italic()), RtOptions::new(width as usize) .initial_indent(Line::from(" ↳ ".dim())) @@ -170,4 +170,36 @@ mod tests { queue.render(Rect::new(0, 0, width, height), &mut buf); assert_snapshot!("render_many_line_message", format!("{buf:?}")); } + + #[test] + fn long_url_like_message_does_not_expand_into_wrapped_ellipsis_rows() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push( + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/session_id=abc123def456ghi789" + .to_string(), + ); + + let width = 36; + let height = queue.desired_height(width); + assert_eq!( + height, 2, + "expected one message row plus hint row for URL-like token" + ); + + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + + let rendered_rows = (0..height) + .map(|y| { + (0..width) + .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' ')) + .collect::() + }) + .collect::>(); + + assert!( + !rendered_rows.iter().any(|row| row.contains('…')), + "expected no wrapped-ellipsis row for URL-like token, got rows: {rendered_rows:?}" + ); + } } diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 26284a7fa1..a749ca5d2f 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -43,6 +43,40 @@ use ratatui::layout::Size; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::widgets::WidgetRef; +use unicode_width::UnicodeWidthStr; + +/// Returns the display width of a cell symbol, ignoring OSC escape sequences. +/// +/// OSC sequences (e.g. OSC 8 hyperlinks: `\x1B]8;;URL\x07`) are terminal +/// control sequences that don't consume display columns. The standard +/// `UnicodeWidthStr::width()` method incorrectly counts the printable +/// characters inside OSC payloads (like `]`, `8`, `;`, and URL characters). +/// This function strips them first so that only visible characters contribute +/// to the width. +fn display_width(s: &str) -> usize { + // Fast path: no escape sequences present. + if !s.contains('\x1B') { + return s.width(); + } + + // Strip OSC sequences: ESC ] ... BEL + let mut visible = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(ch) = chars.next() { + if ch == '\x1B' && chars.clone().next() == Some(']') { + // Consume the ']' and everything up to and including BEL. + chars.next(); // skip ']' + for c in chars.by_ref() { + if c == '\x07' { + break; + } + } + continue; + } + visible.push(ch); + } + visible.width() +} #[derive(Debug, Hash)] pub struct Frame<'a> { @@ -412,7 +446,6 @@ where } use ratatui::buffer::Cell; -use unicode_width::UnicodeWidthStr; #[derive(Debug, IsVariant)] enum DrawCommand { @@ -441,7 +474,7 @@ fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec { let mut column = 0usize; while column < row.len() { let cell = &row[column]; - let width = cell.symbol().width(); + let width = display_width(cell.symbol()); if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() { last_nonblank_column = column + (width.saturating_sub(1)); } @@ -474,9 +507,12 @@ fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec { } } - to_skip = current.symbol().width().saturating_sub(1); + to_skip = display_width(current.symbol()).saturating_sub(1); - let affected_width = std::cmp::max(current.symbol().width(), previous.symbol().width()); + let affected_width = std::cmp::max( + display_width(current.symbol()), + display_width(previous.symbol()), + ); invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1); } updates diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 6e57c21520..14f48529dc 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -10,8 +10,8 @@ use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; use crate::shimmer::shimmer_spans; use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_line; -use crate::wrapping::word_wrap_lines; +use crate::wrapping::adaptive_wrap_line; +use crate::wrapping::adaptive_wrap_lines; use codex_ansi_escape::ansi_escape_line; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::ExecCommandSource; @@ -21,6 +21,8 @@ use itertools::Itertools; use ratatui::prelude::*; use ratatui::style::Modifier; use ratatui::style::Stylize; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; use textwrap::WordSplitter; use unicode_width::UnicodeWidthStr; @@ -202,10 +204,6 @@ impl HistoryCell for ExecCell { } } - fn desired_transcript_height(&self, width: u16) -> u16 { - self.transcript_lines(width).len() as u16 - } - fn transcript_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = vec![]; for (i, call) in self.iter_calls().enumerate() { @@ -214,7 +212,7 @@ impl HistoryCell for ExecCell { } let script = strip_bash_lc_and_escape(&call.command); let highlighted_script = highlight_bash_to_lines(&script); - let cmd_display = word_wrap_lines( + let cmd_display = adaptive_wrap_lines( &highlighted_script, RtOptions::new(width as usize) .initial_indent("$ ".magenta().into()) @@ -227,7 +225,7 @@ impl HistoryCell for ExecCell { let wrap_width = width.max(1) as usize; let wrap_opts = RtOptions::new(wrap_width); for unwrapped in output.formatted_output.lines().map(ansi_escape_line) { - let wrapped = word_wrap_line(&unwrapped, wrap_opts.clone()); + let wrapped = adaptive_wrap_line(&unwrapped, wrap_opts.clone()); push_owned_lines(&wrapped, &mut lines); } } @@ -341,7 +339,7 @@ impl ExecCell { let line = Line::from(line); let initial_indent = Line::from(vec![title.cyan(), " ".into()]); let subsequent_indent = " ".repeat(initial_indent.width()).into(); - let wrapped = word_wrap_line( + let wrapped = adaptive_wrap_line( &line, RtOptions::new(width as usize) .initial_indent(initial_indent) @@ -401,8 +399,9 @@ impl ExecCell { let available_first_width = (width as usize).saturating_sub(header_prefix_width).max(1); let first_opts = RtOptions::new(available_first_width).word_splitter(WordSplitter::NoHyphenation); + let mut first_wrapped: Vec> = Vec::new(); - push_owned_lines(&word_wrap_line(first, first_opts), &mut first_wrapped); + push_owned_lines(&adaptive_wrap_line(first, first_opts), &mut first_wrapped); let mut first_wrapped_iter = first_wrapped.into_iter(); if let Some(first_segment) = first_wrapped_iter.next() { header_line.extend(first_segment); @@ -411,7 +410,7 @@ impl ExecCell { for line in rest { push_owned_lines( - &word_wrap_line(line, continuation_opts.clone()), + &adaptive_wrap_line(line, continuation_opts.clone()), &mut continuation_lines, ); } @@ -470,20 +469,28 @@ impl ExecCell { RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); for line in &raw_output.lines { push_owned_lines( - &word_wrap_line(line, output_opts.clone()), + &adaptive_wrap_line(line, output_opts.clone()), &mut wrapped_output, ); } - let trimmed_output = - Self::truncate_lines_middle(&wrapped_output, display_limit, raw_output.omitted); + let prefixed_output = prefix_lines( + wrapped_output, + Span::from(layout.output_block.initial_prefix).dim(), + Span::from(layout.output_block.subsequent_prefix), + ); + let trimmed_output = Self::truncate_lines_middle( + &prefixed_output, + display_limit, + width, + raw_output.omitted, + Some(Line::from( + Span::from(layout.output_block.subsequent_prefix).dim(), + )), + ); if !trimmed_output.is_empty() { - lines.extend(prefix_lines( - trimmed_output, - Span::from(layout.output_block.initial_prefix).dim(), - Span::from(layout.output_block.subsequent_prefix), - )); + lines.extend(trimmed_output); } } } @@ -504,18 +511,55 @@ impl ExecCell { out } + /// Truncates a list of lines to fit within `max_rows` viewport rows, + /// keeping a head portion and a tail portion with an ellipsis line + /// in between. + /// + /// `max_rows` is measured in viewport rows (the actual space a line + /// occupies after `Paragraph::wrap`), not logical lines. Each line's + /// row cost is computed via `Paragraph::line_count` at the given + /// `width`. This ensures that a single logical line containing a + /// long URL (which wraps to several viewport rows) is properly + /// accounted for. + /// + /// The ellipsis message reports the number of omitted *lines* + /// (logical, not rows) to keep the count stable across terminal + /// widths. `omitted_hint` carries forward any previously reported + /// omitted count (from upstream truncation); `ellipsis_prefix` + /// prepends the output gutter prefix to the ellipsis line. fn truncate_lines_middle( lines: &[Line<'static>], - max: usize, + max_rows: usize, + width: u16, omitted_hint: Option, + ellipsis_prefix: Option>, ) -> Vec> { - if max == 0 { + let width = width.max(1); + if max_rows == 0 { return Vec::new(); } - if lines.len() <= max { + let line_rows: Vec = lines + .iter() + .map(|line| { + let is_whitespace_only = line + .spans + .iter() + .all(|span| span.content.chars().all(char::is_whitespace)); + if is_whitespace_only { + line.width().div_ceil(usize::from(width)).max(1) + } else { + Paragraph::new(Text::from(vec![line.clone()])) + .wrap(Wrap { trim: false }) + .line_count(width) + .max(1) + } + }) + .collect(); + let total_rows: usize = line_rows.iter().sum(); + if total_rows <= max_rows { return lines.to_vec(); } - if max == 1 { + if max_rows == 1 { // Carry forward any previously omitted count and add any // additionally hidden content lines from this truncation. let base = omitted_hint.unwrap_or(0); @@ -526,27 +570,53 @@ impl ExecCell { .len() .saturating_sub(usize::from(omitted_hint.is_some())); let omitted = base + extra; - return vec![Self::ellipsis_line(omitted)]; + return vec![Self::ellipsis_line_with_prefix( + omitted, + ellipsis_prefix.as_ref(), + )]; } - let head = (max - 1) / 2; - let tail = max - head - 1; - let mut out: Vec> = Vec::new(); - - if head > 0 { - out.extend(lines[..head].iter().cloned()); + let head_budget = (max_rows - 1) / 2; + let tail_budget = max_rows - head_budget - 1; + let mut head_lines: Vec> = Vec::new(); + let mut head_rows = 0usize; + let mut head_end = 0usize; + while head_end < lines.len() { + let line_row_count = line_rows[head_end]; + if head_rows + line_row_count > head_budget { + break; + } + head_rows += line_row_count; + head_lines.push(lines[head_end].clone()); + head_end += 1; } + let mut tail_lines_reversed: Vec> = Vec::new(); + let mut tail_rows = 0usize; + let mut tail_start = lines.len(); + while tail_start > head_end { + let idx = tail_start - 1; + let line_row_count = line_rows[idx]; + if tail_rows + line_row_count > tail_budget { + break; + } + tail_rows += line_row_count; + tail_lines_reversed.push(lines[idx].clone()); + tail_start -= 1; + } + + let mut out = head_lines; let base = omitted_hint.unwrap_or(0); let additional = lines .len() - .saturating_sub(head + tail) + .saturating_sub(out.len() + tail_lines_reversed.len()) .saturating_sub(usize::from(omitted_hint.is_some())); - out.push(Self::ellipsis_line(base + additional)); + out.push(Self::ellipsis_line_with_prefix( + base + additional, + ellipsis_prefix.as_ref(), + )); - if tail > 0 { - out.extend(lines[lines.len() - tail..].iter().cloned()); - } + out.extend(tail_lines_reversed.into_iter().rev()); out } @@ -554,6 +624,14 @@ impl ExecCell { fn ellipsis_line(omitted: usize) -> Line<'static> { Line::from(vec![format!("… +{omitted} lines").dim()]) } + + /// Builds an ellipsis line (`… +N lines`) with an optional leading + /// prefix so the ellipsis aligns with the output gutter. + fn ellipsis_line_with_prefix(omitted: usize, prefix: Option<&Line<'static>>) -> Line<'static> { + let mut line = prefix.cloned().unwrap_or_default(); + line.push_span(format!("… +{omitted} lines").dim()); + line + } } #[derive(Clone, Copy)] @@ -612,20 +690,15 @@ const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new( mod tests { use super::*; use codex_protocol::protocol::ExecCommandSource; + use pretty_assertions::assert_eq; #[test] fn user_shell_output_is_limited_by_screen_lines() { - // Construct a user shell exec cell whose aggregated output consists of a - // small number of very long logical lines. These will wrap into many - // on-screen lines at narrow widths. - // - // Use a short marker so it survives wrapping intact inside each - // rendered screen line; the previous test used a marker longer than - // the wrap width, so it was split across lines and the assertion - // never actually saw it. - let marker = "Z"; - let long_chunk = marker.repeat(800); - let aggregated_output = format!("{long_chunk}\n{long_chunk}\n"); + let long_url_like = format!( + "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/{}", + "very-long-segment-".repeat(120), + ); + let aggregated_output = format!("{long_url_like}\n{long_url_like}\n"); // Baseline: how many screen lines would we get if we simply wrapped // all logical lines without any truncation? @@ -653,14 +726,18 @@ mod tests { let mut full_wrapped_output: Vec> = Vec::new(); for line in &raw_output.lines { push_owned_lines( - &word_wrap_line(line, output_opts.clone()), + &adaptive_wrap_line(line, output_opts.clone()), &mut full_wrapped_output, ); } - let full_screen_lines = full_wrapped_output - .iter() - .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) - .count(); + let full_prefixed_output = prefix_lines( + full_wrapped_output, + Span::from(layout.output_block.initial_prefix).dim(), + Span::from(layout.output_block.subsequent_prefix), + ); + let full_screen_lines = Paragraph::new(Text::from(full_prefixed_output)) + .wrap(Wrap { trim: false }) + .line_count(width); // Sanity check: this scenario should produce more screen lines than // the user shell per-call limit when no truncation is applied. If @@ -685,21 +762,207 @@ mod tests { // Use a narrow width so each logical line wraps into many on-screen lines. let lines = cell.command_display_lines(width); + let rendered_rows = Paragraph::new(Text::from(lines.clone())) + .wrap(Wrap { trim: false }) + .line_count(width); + let header_rows = Paragraph::new(Text::from(vec![lines[0].clone()])) + .wrap(Wrap { trim: false }) + .line_count(width); + let output_screen_rows = rendered_rows.saturating_sub(header_rows); - // Count how many rendered lines contain our marker text. This approximates - // the number of visible output "screen lines" for this command. - let output_screen_lines = lines + let contains_ellipsis = lines .iter() - .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) - .count(); + .any(|line| line.spans.iter().any(|span| span.content.contains("… +"))); // Regression guard: previously this scenario could render hundreds of - // wrapped lines because truncation happened before wrapping. Now the - // truncation is applied after wrapping, so the number of visible - // screen lines is bounded by USER_SHELL_TOOL_CALL_MAX_LINES. + // wrapped rows because truncation happened before final viewport + // wrapping. The row-aware truncation now caps visible output rows. assert!( - output_screen_lines <= USER_SHELL_TOOL_CALL_MAX_LINES, - "expected at most {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines of user shell output, got {output_screen_lines}", + output_screen_rows <= USER_SHELL_TOOL_CALL_MAX_LINES, + "expected at most {USER_SHELL_TOOL_CALL_MAX_LINES} output rows, got {output_screen_rows} (total rows: {rendered_rows})", + ); + assert!( + contains_ellipsis, + "expected truncated output to include an ellipsis line" + ); + } + + #[test] + fn truncate_lines_middle_keeps_omitted_count_in_line_units() { + let lines = vec![ + Line::from(" └ short"), + Line::from(" this-is-a-very-long-token-that-wraps-many-rows"), + Line::from(" … +4 lines"), + Line::from(" tail"), + ]; + + let truncated = + ExecCell::truncate_lines_middle(&lines, 2, 12, Some(4), Some(Line::from(" ".dim()))); + let rendered: Vec = truncated + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + + assert!( + rendered.iter().any(|line| line.contains("… +6 lines")), + "expected omitted hint to count hidden lines (not wrapped rows), got: {rendered:?}" + ); + } + + #[test] + fn truncate_lines_middle_does_not_truncate_blank_prefixed_output_lines() { + let mut lines = vec![Line::from(" └ start")]; + lines.extend(std::iter::repeat_n(Line::from(" "), 26)); + lines.push(Line::from(" end")); + + let truncated = ExecCell::truncate_lines_middle(&lines, 28, 80, None, None); + + assert_eq!(truncated, lines); + } + + #[test] + fn command_display_does_not_split_long_url_token() { + let url = "http://example.com/long-url-with-dashes-wider-than-terminal-window/blah-blah-blah-text/more-gibberish-text"; + + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), format!("echo {url}")], + parsed: Vec::new(), + output: None, + source: ExecCommandSource::UserShell, + start_time: None, + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, false); + let rendered: Vec = cell + .command_display_lines(36) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + + assert_eq!( + rendered.iter().filter(|line| line.contains(url)).count(), + 1, + "expected full URL in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn exploring_display_does_not_split_long_url_like_search_query() { + let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path"; + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), "rg foo".into()], + parsed: vec![ParsedCommand::Search { + cmd: format!("rg {url_like}"), + query: Some(url_like.to_string()), + path: None, + }], + output: None, + source: ExecCommandSource::Agent, + start_time: None, + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, false); + let rendered: Vec = cell + .display_lines(36) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(url_like)) + .count(), + 1, + "expected full URL-like query in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn output_display_does_not_split_long_url_like_token_without_scheme() { + let url = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/session_id=abc123def456ghi789jkl012mno345pqr678"; + + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), "echo done".into()], + parsed: Vec::new(), + output: Some(CommandOutput { + exit_code: 0, + formatted_output: String::new(), + aggregated_output: url.to_string(), + }), + source: ExecCommandSource::UserShell, + start_time: None, + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, false); + let rendered: Vec = cell + .command_display_lines(36) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + + assert_eq!( + rendered.iter().filter(|line| line.contains(url)).count(), + 1, + "expected full URL-like token in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn desired_transcript_height_accounts_for_wrapped_url_like_rows() { + let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/that/keeps/going/for/testing/purposes"; + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), "echo done".into()], + parsed: Vec::new(), + output: Some(CommandOutput { + exit_code: 0, + formatted_output: url.to_string(), + aggregated_output: url.to_string(), + }), + source: ExecCommandSource::Agent, + start_time: None, + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, false); + let width: u16 = 36; + let logical_height = cell.transcript_lines(width).len() as u16; + let wrapped_height = cell.desired_transcript_height(width); + + assert!( + wrapped_height > logical_height, + "expected transcript height to account for wrapped URL-like rows, logical_height={logical_height}, wrapped_height={wrapped_height}" ); } } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index bd08c370e4..5ec09c25ff 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -34,8 +34,8 @@ use crate::ui_consts::LIVE_PREFIX_COLS; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_line; -use crate::wrapping::word_wrap_lines; +use crate::wrapping::adaptive_wrap_line; +use crate::wrapping::adaptive_wrap_lines; use base64::Engine; use codex_core::config::Config; use codex_core::config::types::McpServerTransportConfig; @@ -82,9 +82,26 @@ use unicode_width::UnicodeWidthStr; /// Represents an event to display in the conversation history. Returns its /// `Vec>` representation to make it easier to display in a /// scrollable list. +/// A single renderable unit of conversation history. +/// +/// Each cell produces logical `Line`s and reports how many viewport +/// rows those lines occupy at a given terminal width. The default +/// height implementations use `Paragraph::wrap` to account for lines +/// that overflow the viewport width (e.g. long URLs that are kept +/// intact by adaptive wrapping). Concrete types only need to override +/// heights when they apply additional layout logic beyond what +/// `Paragraph::line_count` captures. pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { + /// Returns the logical lines for the main chat viewport. fn display_lines(&self, width: u16) -> Vec>; + /// Returns the number of viewport rows needed to render this cell. + /// + /// The default delegates to `Paragraph::line_count` with + /// `Wrap { trim: false }`, which measures the actual row count after + /// ratatui's viewport-level character wrapping. This is critical + /// for lines containing URL-like tokens that are wider than the + /// terminal — the logical line count would undercount. fn desired_height(&self, width: u16) -> u16 { Paragraph::new(Text::from(self.display_lines(width))) .wrap(Wrap { trim: false }) @@ -93,13 +110,24 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { .unwrap_or(0) } + /// Returns lines for the transcript overlay (`Ctrl+T`). + /// + /// Defaults to `display_lines`. Override when the transcript + /// representation differs (e.g. `ExecCell` shows all calls with + /// `$`-prefixed commands and exit status). fn transcript_lines(&self, width: u16) -> Vec> { self.display_lines(width) } + /// Returns the number of viewport rows for the transcript overlay. + /// + /// Uses the same `Paragraph::line_count` measurement as + /// `desired_height`. Contains a workaround for a ratatui bug where + /// a single whitespace-only line reports 2 rows instead of 1. fn desired_transcript_height(&self, width: u16) -> u16 { let lines = self.transcript_lines(width); - // Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines. + // Workaround: ratatui's line_count returns 2 for a single + // whitespace-only line. Clamp to 1 in that case. if let [line] = &lines[..] && line .spans @@ -138,15 +166,16 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { impl Renderable for Box { fn render(&self, area: Rect, buf: &mut Buffer) { let lines = self.display_lines(area.width); + let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); let y = if area.height == 0 { 0 } else { - let overflow = lines.len().saturating_sub(usize::from(area.height)); + let overflow = paragraph + .line_count(area.width) + .saturating_sub(usize::from(area.height)); u16::try_from(overflow).unwrap_or(u16::MAX) }; - Paragraph::new(Text::from(lines)) - .scroll((y, 0)) - .render(area, buf); + paragraph.scroll((y, 0)).render(area, buf); } fn desired_height(&self, width: u16) -> u16 { HistoryCell::desired_height(self.as_ref(), width) @@ -266,7 +295,7 @@ impl HistoryCell for UserHistoryCell { let wrapped_remote_images = if self.remote_image_urls.is_empty() { None } else { - Some(word_wrap_lines( + Some(adaptive_wrap_lines( self.remote_image_urls .iter() .enumerate() @@ -282,7 +311,7 @@ impl HistoryCell for UserHistoryCell { None } else if self.text_elements.is_empty() { let message_without_trailing_newlines = self.message.trim_end_matches(['\r', '\n']); - let wrapped = word_wrap_lines( + let wrapped = adaptive_wrap_lines( message_without_trailing_newlines .split('\n') .map(|line| Line::from(line).style(style)), @@ -299,7 +328,7 @@ impl HistoryCell for UserHistoryCell { style, element_style, ); - let wrapped = word_wrap_lines( + let wrapped = adaptive_wrap_lines( raw_lines, RtOptions::new(usize::from(wrap_width)) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), @@ -336,20 +365,6 @@ impl HistoryCell for UserHistoryCell { lines.push(Line::from("").style(style)); lines } - - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width) - .len() - .try_into() - .unwrap_or(u16::MAX) - } - - fn desired_transcript_height(&self, width: u16) -> u16 { - self.display_lines(width) - .len() - .try_into() - .unwrap_or(u16::MAX) - } } #[derive(Debug)] @@ -388,7 +403,7 @@ impl ReasoningSummaryCell { }) .collect::>(); - word_wrap_lines( + adaptive_wrap_lines( &summary_lines, RtOptions::new(width as usize) .initial_indent("• ".dim().into()) @@ -406,21 +421,9 @@ impl HistoryCell for ReasoningSummaryCell { } } - fn desired_height(&self, width: u16) -> u16 { - if self.transcript_only { - 0 - } else { - self.lines(width).len() as u16 - } - } - fn transcript_lines(&self, width: u16) -> Vec> { self.lines(width) } - - fn desired_transcript_height(&self, width: u16) -> u16 { - self.lines(width).len() as u16 - } } #[derive(Debug)] @@ -440,7 +443,7 @@ impl AgentMessageCell { impl HistoryCell for AgentMessageCell { fn display_lines(&self, width: u16) -> Vec> { - word_wrap_lines( + adaptive_wrap_lines( &self.lines, RtOptions::new(width as usize) .initial_indent(if self.is_first_line { @@ -557,14 +560,7 @@ impl HistoryCell for PrefixedWrappedHistoryCell { let opts = RtOptions::new(width.max(1) as usize) .initial_indent(self.initial_prefix.clone()) .subsequent_indent(self.subsequent_prefix.clone()); - let wrapped = word_wrap_lines(&self.text, opts); - let mut out = Vec::new(); - push_owned_lines(&wrapped, &mut out); - out - } - - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width).len() as u16 + adaptive_wrap_lines(&self.text, opts) } } @@ -605,7 +601,7 @@ impl HistoryCell for UnifiedExecInteractionCell { let header = Line::from(header_spans); let mut out: Vec> = Vec::new(); - let header_wrapped = word_wrap_line(&header, RtOptions::new(wrap_width)); + let header_wrapped = adaptive_wrap_line(&header, RtOptions::new(wrap_width)); push_owned_lines(&header_wrapped, &mut out); if waited_only { @@ -618,7 +614,7 @@ impl HistoryCell for UnifiedExecInteractionCell { .map(|line| Line::from(line.to_string())) .collect(); - let input_wrapped = word_wrap_lines( + let input_wrapped = adaptive_wrap_lines( input_lines, RtOptions::new(wrap_width) .initial_indent(Line::from(" └ ".dim())) @@ -627,10 +623,6 @@ impl HistoryCell for UnifiedExecInteractionCell { out.extend(input_wrapped); out } - - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width).len() as u16 - } } pub(crate) fn new_unified_exec_interaction( @@ -1397,7 +1389,7 @@ impl HistoryCell for McpToolCallCell { let opts = RtOptions::new((width as usize).saturating_sub(4)) .initial_indent("".into()) .subsequent_indent(" ".into()); - let wrapped = word_wrap_line(&invocation_line, opts); + let wrapped = adaptive_wrap_line(&invocation_line, opts); let body_lines: Vec> = wrapped.iter().map(line_to_static).collect(); lines.extend(prefix_lines(body_lines, " └ ".dim(), " ".into())); } @@ -1414,7 +1406,7 @@ impl HistoryCell for McpToolCallCell { let text = Self::render_content_block(block, detail_wrap_width); for segment in text.split('\n') { let line = Line::from(segment.to_string().dim()); - let wrapped = word_wrap_line( + let wrapped = adaptive_wrap_line( &line, RtOptions::new(detail_wrap_width) .initial_indent("".into()) @@ -1432,7 +1424,7 @@ impl HistoryCell for McpToolCallCell { width as usize, ); let err_line = Line::from(err_text.dim()); - let wrapped = word_wrap_line( + let wrapped = adaptive_wrap_line( &err_line, RtOptions::new(detail_wrap_width) .initial_indent("".into()) @@ -1645,11 +1637,9 @@ impl HistoryCell for DeprecationNoticeCell { let wrap_width = width.saturating_sub(4).max(1) as usize; if let Some(details) = &self.details { - let line = textwrap::wrap(details, wrap_width) - .into_iter() - .map(|s| s.to_string().dim().into()) - .collect::>(); - lines.extend(line); + let detail_line = Line::from(details.clone().dim()); + let wrapped = adaptive_wrap_line(&detail_line, RtOptions::new(wrap_width)); + push_owned_lines(&wrapped, &mut lines); } lines @@ -1974,17 +1964,14 @@ fn wrap_with_prefix( subsequent_prefix: Span<'static>, style: Style, ) -> Vec> { - let prefix_width = initial_prefix - .content - .width() - .max(subsequent_prefix.content.width()); - let wrap_width = width.saturating_sub(prefix_width).max(1); - let wrapped = textwrap::wrap(text, wrap_width); - let wrapped_lines = wrapped - .into_iter() - .map(|segment| Span::from(segment.to_string()).set_style(style).into()) - .collect::>>(); - prefix_lines(wrapped_lines, initial_prefix, subsequent_prefix) + let line = Line::from(vec![Span::from(text.to_string()).set_style(style)]); + let opts = RtOptions::new(width.max(1)) + .initial_indent(Line::from(vec![initial_prefix])) + .subsequent_indent(Line::from(vec![subsequent_prefix])); + let wrapped = adaptive_wrap_line(&line, opts); + let mut out = Vec::new(); + push_owned_lines(&wrapped, &mut out); + out } /// Split a request_user_input answer into option labels and an optional freeform note. @@ -2077,10 +2064,11 @@ impl HistoryCell for PlanUpdateCell { fn display_lines(&self, width: u16) -> Vec> { let render_note = |text: &str| -> Vec> { let wrap_width = width.saturating_sub(4).max(1) as usize; - textwrap::wrap(text, wrap_width) - .into_iter() - .map(|s| s.to_string().dim().italic().into()) - .collect() + let note = Line::from(text.to_string().dim().italic()); + let wrapped = adaptive_wrap_line(¬e, RtOptions::new(wrap_width)); + let mut out = Vec::new(); + push_owned_lines(&wrapped, &mut out); + out }; let render_step = |status: &StepStatus, text: &str| -> Vec> { @@ -2089,16 +2077,15 @@ impl HistoryCell for PlanUpdateCell { StepStatus::InProgress => ("□ ", Style::default().cyan().bold()), StepStatus::Pending => ("□ ", Style::default().dim()), }; - let wrap_width = (width as usize) - .saturating_sub(4) - .saturating_sub(box_str.width()) - .max(1); - let parts = textwrap::wrap(text, wrap_width); - let step_text = parts - .into_iter() - .map(|s| s.to_string().set_style(step_style).into()) - .collect(); - prefix_lines(step_text, box_str.into(), " ".into()) + + let opts = RtOptions::new(width.saturating_sub(4).max(1) as usize) + .initial_indent(box_str.into()) + .subsequent_indent(" ".into()); + let step = Line::from(text.to_string().set_style(step_style)); + let wrapped = adaptive_wrap_line(&step, opts); + let mut out = Vec::new(); + push_owned_lines(&wrapped, &mut out); + out }; let mut lines: Vec> = vec![]; @@ -2713,6 +2700,113 @@ mod tests { ); } + #[test] + fn prefixed_wrapped_history_cell_does_not_split_url_like_token() { + let url_like = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let cell = PrefixedWrappedHistoryCell::new(Line::from(url_like), "✔ ".green(), " "); + let rendered = render_lines(&cell.display_lines(24)); + + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(url_like)) + .count(), + 1, + "expected full URL-like token in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn unified_exec_interaction_cell_does_not_split_url_like_stdin_token() { + let url_like = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let cell = UnifiedExecInteractionCell::new(Some("true".to_string()), url_like.to_string()); + let rendered = render_lines(&cell.display_lines(24)); + + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(url_like)) + .count(), + 1, + "expected full URL-like token in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn prefixed_wrapped_history_cell_height_matches_wrapped_rendering() { + let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path"; + let cell: Box = Box::new(PrefixedWrappedHistoryCell::new( + Line::from(url_like), + "✔ ".green(), + " ", + )); + + let width: u16 = 24; + let logical_height = cell.display_lines(width).len() as u16; + let wrapped_height = cell.desired_height(width); + assert!( + wrapped_height > logical_height, + "expected wrapped height to exceed logical line count ({logical_height}), got {wrapped_height}" + ); + + let area = Rect::new(0, 0, width, wrapped_height); + let mut buf = ratatui::buffer::Buffer::empty(area); + cell.render(area, &mut buf); + + let first_row = (0..area.width) + .map(|x| { + let symbol = buf[(x, 0)].symbol(); + if symbol.is_empty() { + ' ' + } else { + symbol.chars().next().unwrap_or(' ') + } + }) + .collect::(); + assert!( + first_row.contains("✔"), + "expected first rendered row to keep the prefix visible, got: {first_row:?}" + ); + } + + #[test] + fn unified_exec_interaction_cell_height_matches_wrapped_rendering() { + let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path"; + let cell: Box = Box::new(UnifiedExecInteractionCell::new( + Some("true".to_string()), + url_like.to_string(), + )); + + let width: u16 = 24; + let logical_height = cell.display_lines(width).len() as u16; + let wrapped_height = cell.desired_height(width); + assert!( + wrapped_height > logical_height, + "expected wrapped height to exceed logical line count ({logical_height}), got {wrapped_height}" + ); + + let area = Rect::new(0, 0, width, wrapped_height); + let mut buf = ratatui::buffer::Buffer::empty(area); + cell.render(area, &mut buf); + + let first_row = (0..area.width) + .map(|x| { + let symbol = buf[(x, 0)].symbol(); + if symbol.is_empty() { + ' ' + } else { + symbol.chars().next().unwrap_or(' ') + } + }) + .collect::(); + assert!( + first_row.contains("Interacted with"), + "expected first rendered row to keep the header visible, got: {first_row:?}" + ); + } + #[test] fn web_search_history_cell_snapshot() { let query = @@ -3564,6 +3658,55 @@ mod tests { assert!(rendered.iter().any(|line| line.contains("tokenized"))); } + #[test] + fn render_uses_wrapping_for_long_url_like_line() { + let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/that/keeps/going/for/testing/purposes-only-and-does/not/need/to/resolve/index.html?session_id=abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"; + let cell: Box = Box::new(UserHistoryCell { + message: url.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }); + + let width: u16 = 52; + let height = cell.desired_height(width); + assert!( + height > 1, + "expected wrapped height for long URL, got {height}" + ); + + let area = Rect::new(0, 0, width, height); + let mut buf = ratatui::buffer::Buffer::empty(area); + cell.render(area, &mut buf); + + let rendered = (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| { + let symbol = buf[(x, y)].symbol(); + if symbol.is_empty() { + ' ' + } else { + symbol.chars().next().unwrap_or(' ') + } + }) + .collect::() + }) + .collect::>(); + let rendered_blob = rendered.join("\n"); + + assert!( + rendered_blob.contains("session_id=abc123"), + "expected URL tail to be visible after wrapping, got:\n{rendered_blob}" + ); + + let non_empty_rows = rendered.iter().filter(|row| !row.trim().is_empty()).count() as u16; + assert!( + non_empty_rows > 3, + "expected long URL to span multiple visible rows, got:\n{rendered_blob}" + ); + } + #[test] fn plan_update_with_note_and_wrapping_snapshot() { // Long explanation forces wrapping; include long step text to verify step wrapping and alignment. @@ -3616,6 +3759,43 @@ mod tests { let rendered = render_lines(&lines).join("\n"); insta::assert_snapshot!(rendered); } + + #[test] + fn plan_update_does_not_split_url_like_tokens_in_note_or_step() { + let note_url = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let step_url = "example.test/api/v1/projects/beta-team/releases/2026-02-17/builds/0987654321/artifacts/reports/performance"; + let update = UpdatePlanArgs { + explanation: Some(format!( + "Investigate failures under {note_url} immediately." + )), + plan: vec![PlanItemArg { + step: format!("Validate callbacks under {step_url} before rollout."), + status: StepStatus::InProgress, + }], + }; + + let cell = new_plan_update(update); + let rendered = render_lines(&cell.display_lines(30)); + + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(note_url)) + .count(), + 1, + "expected full note URL-like token in one rendered line, got: {rendered:?}" + ); + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(step_url)) + .count(), + 1, + "expected full step URL-like token in one rendered line, got: {rendered:?}" + ); + } + #[test] fn reasoning_summary_block() { let cell = new_reasoning_summary_block( @@ -3629,6 +3809,50 @@ mod tests { assert_eq!(rendered_transcript, vec!["• Detailed reasoning goes here."]); } + #[test] + fn reasoning_summary_height_matches_wrapped_rendering_for_url_like_content() { + let summary = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/that/keeps/going"; + let cell: Box = Box::new(ReasoningSummaryCell::new( + "High level reasoning".to_string(), + summary.to_string(), + false, + )); + let width: u16 = 24; + + let logical_height = cell.display_lines(width).len() as u16; + let wrapped_height = cell.desired_height(width); + let expected_wrapped_height = Paragraph::new(Text::from(cell.display_lines(width))) + .wrap(Wrap { trim: false }) + .line_count(width) as u16; + assert_eq!(wrapped_height, expected_wrapped_height); + assert!( + wrapped_height >= logical_height, + "expected wrapped height to be at least logical line count ({logical_height}), got {wrapped_height}" + ); + + let wrapped_transcript_height = cell.desired_transcript_height(width); + assert_eq!(wrapped_transcript_height, wrapped_height); + + let area = Rect::new(0, 0, width, wrapped_height); + let mut buf = ratatui::buffer::Buffer::empty(area); + cell.render(area, &mut buf); + + let first_row = (0..area.width) + .map(|x| { + let symbol = buf[(x, 0)].symbol(); + if symbol.is_empty() { + ' ' + } else { + symbol.chars().next().unwrap_or(' ') + } + }) + .collect::(); + assert!( + first_row.contains("•"), + "expected first rendered row to keep summary bullet visible, got: {first_row:?}" + ); + } + #[test] fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() { let cell = new_reasoning_summary_block("Detailed reasoning goes here.".to_string()); diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 36ef47da5e..9fe4316a98 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -2,9 +2,16 @@ use std::fmt; use std::io; use std::io::Write; -use crate::wrapping::word_wrap_lines_borrowed; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_line; +use crate::wrapping::line_contains_url_like; +use crate::wrapping::line_has_mixed_url_and_non_url_tokens; use crossterm::Command; +use crossterm::cursor::MoveDown; use crossterm::cursor::MoveTo; +use crossterm::cursor::MoveToColumn; +use crossterm::cursor::RestorePosition; +use crossterm::cursor::SavePosition; use crossterm::queue; use crossterm::style::Color as CColor; use crossterm::style::Colors; @@ -38,10 +45,35 @@ where let last_cursor_pos = terminal.last_known_cursor_pos; let writer = terminal.backend_mut(); - // Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same - // formatting as the TUI. This avoids character-level hard wrapping by the terminal. - let wrapped = word_wrap_lines_borrowed(&lines, area.width.max(1) as usize); - let wrapped_lines = wrapped.len() as u16; + // Pre-wrap lines for terminal scrollback. Three paths: + // + // - URL-only-ish lines are kept intact (no hard newlines inserted) so that + // terminal emulators can match them as clickable links. The + // terminal will character-wrap these lines at the viewport + // boundary. + // - Mixed lines (URL + non-URL prose) are adaptively wrapped so + // non-URL text still wraps naturally while URL tokens remain + // unsplit. + // - Non-URL lines also flow through adaptive wrapping; behavior is + // equivalent to standard wrapping when no URL is present. + let wrap_width = area.width.max(1) as usize; + let mut wrapped = Vec::new(); + let mut wrapped_rows = 0usize; + + for line in &lines { + let line_wrapped = + if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) { + vec![line.clone()] + } else { + adaptive_wrap_line(line, RtOptions::new(wrap_width)) + }; + wrapped_rows += line_wrapped + .iter() + .map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width)) + .sum::(); + 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. @@ -94,6 +126,18 @@ where for line in wrapped { queue!(writer, Print("\r\n"))?; + // URL lines can be wider than the terminal and will + // character-wrap onto continuation rows. Pre-clear those rows + // so stale content from a previously longer line is erased. + let physical_rows = line.width().max(1).div_ceil(wrap_width); + if physical_rows > 1 { + queue!(writer, SavePosition)?; + for _ in 1..physical_rows { + queue!(writer, MoveDown(1), MoveToColumn(0))?; + queue!(writer, Clear(ClearType::UntilNewLine))?; + } + queue!(writer, RestorePosition)?; + } queue!( writer, SetColors(Colors::new( @@ -527,4 +571,163 @@ mod tests { ); } } + + #[test] + fn vt100_prefixed_url_keeps_prefix_and_url_on_same_row() { + let width: u16 = 48; + 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(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let url = "http://a-long-url.com/this/that/blablablab/new.aspx/many_people_like_how"; + let line: Line<'static> = Line::from(vec![" │ ".into(), url.into()]); + + insert_history_lines(&mut term, vec![line]).expect("insert history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + + assert!( + rows.iter().any(|r| r.contains("│ http://a-long-url.com")), + "expected prefix and URL on same row, rows: {rows:?}" + ); + assert!( + !rows.iter().any(|r| r.trim_end() == "│"), + "unexpected orphan prefix row, rows: {rows:?}" + ); + } + + #[test] + fn vt100_prefixed_url_like_without_scheme_keeps_prefix_and_token_on_same_row() { + let width: u16 = 48; + 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(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let url_like = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let line: Line<'static> = Line::from(vec![" │ ".into(), url_like.into()]); + + insert_history_lines(&mut term, vec![line]).expect("insert history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + + assert!( + rows.iter() + .any(|r| r.contains("│ example.test/api/v1/projects")), + "expected prefix and URL-like token on same row, rows: {rows:?}" + ); + assert!( + !rows.iter().any(|r| r.trim_end() == "│"), + "unexpected orphan prefix row, rows: {rows:?}" + ); + } + + #[test] + fn vt100_prefixed_mixed_url_line_wraps_suffix_words_together() { + let width: u16 = 24; + let height: u16 = 10; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let url = "https://example.test/path/abcdef12345"; + let line: Line<'static> = Line::from(vec![ + " │ ".into(), + "see ".into(), + url.into(), + " tail words".into(), + ]); + + insert_history_lines(&mut term, vec![line]).expect("insert mixed history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + assert!( + rows.iter().any(|r| r.contains("│ see")), + "expected prefixed prose before URL, rows: {rows:?}" + ); + assert!( + rows.iter().any(|r| r.contains("tail words")), + "expected suffix words to wrap as a phrase, rows: {rows:?}" + ); + } + + #[test] + fn vt100_unwrapped_url_like_clears_continuation_rows() { + let width: u16 = 20; + let height: u16 = 10; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let filler_line: Line<'static> = Line::from(vec![ + " │ ".into(), + "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".into(), + ]); + insert_history_lines(&mut term, vec![filler_line]).expect("insert filler history"); + + let url_like = "example.test/api/v1/short"; + let url_line: Line<'static> = Line::from(vec![" │ ".into(), url_like.into()]); + insert_history_lines(&mut term, vec![url_line]).expect("insert url-like history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + let first_row = rows + .iter() + .position(|row| row.contains("│ example.test/api")) + .unwrap_or_else(|| panic!("expected url-like first row in screen rows: {rows:?}")); + assert!( + first_row + 1 < rows.len(), + "expected a continuation row for wrapped URL-like line, rows: {rows:?}" + ); + let continuation_row = rows[first_row + 1].trim_end(); + + assert!( + continuation_row.contains("/v1/short") || continuation_row.contains("short"), + "expected continuation row to contain wrapped URL-like tail, got: {continuation_row:?}" + ); + assert!( + !continuation_row.contains('X'), + "expected continuation row to be cleared before writing wrapped URL-like content, got: {continuation_row:?}" + ); + } + + #[test] + fn vt100_long_unwrapped_url_does_not_insert_extra_blank_gap_before_content() { + let width: u16 = 56; + let height: u16 = 24; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let prompt = "Write a long URL as output for testing"; + insert_history_lines(&mut term, vec![Line::from(prompt)]).expect("insert prompt line"); + + let long_url = format!( + "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/{}", + "very-long-segment-".repeat(16), + ); + let url_line: Line<'static> = Line::from(vec!["• ".into(), long_url.into()]); + insert_history_lines(&mut term, vec![url_line]).expect("insert long url line"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + let prompt_row = rows + .iter() + .position(|row| row.contains("Write a long URL as output for testing")) + .unwrap_or_else(|| panic!("expected prompt row in screen rows: {rows:?}")); + let url_row = rows + .iter() + .position(|row| row.contains("• https://example.test/api")) + .unwrap_or_else(|| panic!("expected URL first row in screen rows: {rows:?}")); + + assert!( + url_row <= prompt_row + 2, + "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:?}", + ); + } } diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 4ba7b61316..35b0e8f5fe 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -1,6 +1,6 @@ use crate::render::line_utils::line_to_static; use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_line; +use crate::wrapping::adaptive_wrap_line; use pulldown_cmark::CodeBlockKind; use pulldown_cmark::CowStr; use pulldown_cmark::Event; @@ -446,7 +446,7 @@ where let opts = RtOptions::new(width) .initial_indent(self.current_initial_indent.clone().into()) .subsequent_indent(self.current_subsequent_indent.clone().into()); - for wrapped in word_wrap_line(&line, opts) { + for wrapped in adaptive_wrap_line(&line, opts) { let owned = line_to_static(&wrapped).style(style); self.text.lines.push(owned); } @@ -675,4 +675,18 @@ mod tests { vec!["fn main() { println!(\"hi from a long line\"); }".to_string(),] ); } + + #[test] + fn does_not_split_long_url_like_token_without_scheme() { + let url_like = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let rendered = render_markdown_text_with_width(url_like, Some(24)); + let lines = lines_to_strings(&rendered); + + assert_eq!( + lines.iter().filter(|line| line.contains(url_like)).count(), + 1, + "expected full URL-like token in one rendered line, got: {lines:?}" + ); + } } diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index f8b240e16f..9c28cc1ea1 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -323,7 +323,11 @@ impl ModelMigrationScreen { let wrap_width = (content_width > 0).then_some(content_width as usize); let rendered = render_markdown_text_with_width(markdown, wrap_width); for line in rendered.lines { - column.push(line.inset(Insets::tlbr(0, horizontal_inset, 0, 0))); + column.push( + Paragraph::new(line) + .wrap(Wrap { trim: false }) + .inset(Insets::tlbr(0, horizontal_inset, 0, 0)), + ); } } @@ -393,6 +397,7 @@ fn fill_migration_markdown(template: &str, current_model: &str, target_model: &s #[cfg(test)] mod tests { + use super::ModelMigrationCopy; use super::ModelMigrationScreen; use super::migration_copy_for_models; use crate::custom_terminal::Terminal; @@ -578,4 +583,34 @@ mod tests { super::ModelMigrationOutcome::Rejected )); } + + #[test] + fn markdown_prompt_keeps_long_url_tail_visible_when_narrow() { + let long_url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/tail42"; + let screen = ModelMigrationScreen::new( + FrameRequester::test_dummy(), + ModelMigrationCopy { + heading: Vec::new(), + content: Vec::new(), + can_opt_out: false, + markdown: Some(long_url.to_string()), + }, + ); + + let backend = VT100Backend::new(40, 16); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, 40, 16)); + + { + let mut frame = terminal.get_frame(); + frame.render_widget_ref(&screen, frame.area()); + } + terminal.flush().expect("flush"); + + let rendered = terminal.backend().to_string(); + assert!( + rendered.contains("tail42"), + "expected wrapped markdown URL tail to remain visible, got:\n{rendered}" + ); + } } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index c639d24a08..23b1ff4aad 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -39,6 +39,41 @@ use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; + +/// Marks buffer cells that have cyan+underlined style as an OSC 8 hyperlink. +/// +/// Terminal emulators recognise the OSC 8 escape sequence and treat the entire +/// marked region as a single clickable link, regardless of row wrapping. This +/// is necessary because ratatui's cell-based rendering emits `MoveTo` at every +/// row boundary, which breaks normal terminal URL detection for long URLs that +/// wrap across multiple rows. +pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { + // Sanitize: strip any characters that could break out of the OSC 8 + // sequence (ESC or BEL) to prevent terminal escape injection from a + // malformed or compromised upstream URL. + let safe_url: String = url + .chars() + .filter(|&c| c != '\x1B' && c != '\x07') + .collect(); + if safe_url.is_empty() { + return; + } + + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + let cell = &mut buf[(x, y)]; + // Only mark cells that carry the URL's distinctive style. + if cell.fg != Color::Cyan || !cell.modifier.contains(Modifier::UNDERLINED) { + continue; + } + let sym = cell.symbol().to_string(); + if sym.trim().is_empty() { + continue; + } + cell.set_symbol(&format!("\x1B]8;;{safe_url}\x07{sym}\x1B]8;;\x07")); + } + } +} use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Notify; @@ -370,7 +405,7 @@ impl AuthModeWidget { let mut lines = vec![spans.into(), "".into()]; let sign_in_state = self.sign_in_state.read().unwrap(); - if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state + let auth_url = if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state && !state.auth_url.is_empty() { lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into()); @@ -386,12 +421,21 @@ impl AuthModeWidget { ".".into(), ])); lines.push("".into()); - } + Some(state.auth_url.clone()) + } else { + None + }; lines.push(" Press Esc to cancel".dim().into()); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); + + // Wrap cyan+underlined URL cells with OSC 8 so the terminal treats + // the entire region as a single clickable hyperlink. + if let Some(url) = &auth_url { + mark_url_hyperlink(buf, area, url); + } } fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) { @@ -842,4 +886,98 @@ mod tests { )); assert_eq!(widget.login_status, LoginStatus::NotAuthenticated); } + + /// Collects all buffer cell symbols that contain the OSC 8 open sequence + /// for the given URL. Returns the concatenated "inner" characters. + fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String { + let open = format!("\x1B]8;;{url}\x07"); + let close = "\x1B]8;;\x07"; + let mut chars = String::new(); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + let sym = buf[(x, y)].symbol(); + if let Some(rest) = sym.strip_prefix(open.as_str()) + && let Some(ch) = rest.strip_suffix(close) + { + chars.push_str(ch); + } + } + } + chars + } + + #[test] + fn continue_in_browser_renders_osc8_hyperlink() { + let (widget, _tmp) = widget_forced_chatgpt(); + let url = "https://auth.example.com/login?state=abc123"; + *widget.sign_in_state.write().unwrap() = + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + auth_url: url.to_string(), + shutdown_flag: None, + }); + + // Render into a narrow buffer so the URL wraps across multiple rows. + let area = Rect::new(0, 0, 30, 20); + let mut buf = Buffer::empty(area); + widget.render_continue_in_browser(area, &mut buf); + + // Every character of the URL should be present as an OSC 8 cell. + let found = collect_osc8_chars(&buf, area, url); + assert_eq!(found, url, "OSC 8 hyperlink should cover the full URL"); + } + + #[test] + fn mark_url_hyperlink_wraps_cyan_underlined_cells() { + let url = "https://example.com"; + let area = Rect::new(0, 0, 20, 1); + let mut buf = Buffer::empty(area); + + // Manually write some cyan+underlined characters to simulate a rendered URL. + for (i, ch) in "example".chars().enumerate() { + let cell = &mut buf[(i as u16, 0)]; + cell.set_symbol(&ch.to_string()); + cell.fg = Color::Cyan; + cell.modifier = Modifier::UNDERLINED; + } + // Leave a plain cell that should NOT be marked. + buf[(7, 0)].set_symbol("X"); + + mark_url_hyperlink(&mut buf, area, url); + + // Each cyan+underlined cell should now carry the OSC 8 wrapper. + let found = collect_osc8_chars(&buf, area, url); + assert_eq!(found, "example"); + + // The plain "X" cell should be untouched. + assert_eq!(buf[(7, 0)].symbol(), "X"); + } + + #[test] + fn mark_url_hyperlink_sanitizes_control_chars() { + let area = Rect::new(0, 0, 10, 1); + let mut buf = Buffer::empty(area); + + // One cyan+underlined cell to mark. + let cell = &mut buf[(0, 0)]; + cell.set_symbol("a"); + cell.fg = Color::Cyan; + cell.modifier = Modifier::UNDERLINED; + + // URL contains ESC and BEL that could break the OSC 8 sequence. + let malicious_url = "https://evil.com/\x1B]8;;\x07injected"; + mark_url_hyperlink(&mut buf, area, malicious_url); + + let sym = buf[(0, 0)].symbol().to_string(); + // The sanitized URL retains `]` (printable) but strips ESC and BEL. + let sanitized = "https://evil.com/]8;;injected"; + assert!( + sym.contains(sanitized), + "symbol should contain sanitized URL, got: {sym:?}" + ); + // The injected close-sequence must not survive: \x1B and \x07 are gone. + assert!( + !sym.contains("\x1B]8;;\x07injected"), + "symbol must not contain raw control chars from URL" + ); + } } diff --git a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs index f4949fe13f..c8a6345843 100644 --- a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs +++ b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs @@ -21,6 +21,7 @@ use super::AuthModeWidget; use super::ContinueInBrowserState; use super::ContinueWithDeviceCodeState; use super::SignInState; +use super::mark_url_hyperlink; pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, mut opts: ServerOptions) { opts.open_browser = false; @@ -153,7 +154,8 @@ pub(super) fn render_device_code_login( let mut lines = vec![spans.into(), "".into()]; - if let Some(device_code) = &state.device_code { + // Capture the verification URL for OSC 8 hyperlink marking after render. + let verification_url = if let Some(device_code) = &state.device_code { lines.push(" 1. Open this link in your browser and sign in".into()); lines.push("".into()); lines.push(Line::from(vec![ @@ -176,15 +178,23 @@ pub(super) fn render_device_code_login( .into(), ); lines.push("".into()); + Some(device_code.verification_url.clone()) } else { lines.push(" Requesting a one-time code...".dim().into()); lines.push("".into()); - } + None + }; lines.push(" Press Esc to cancel".dim().into()); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); + + // Wrap cyan+underlined URL cells with OSC 8 so the terminal treats + // the entire region as a single clickable hyperlink. + if let Some(url) = &verification_url { + mark_url_hyperlink(buf, area, url); + } } fn device_code_attempt_matches(state: &SignInState, cancel: &Arc) -> bool { diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 00c18dd71a..a6d415da18 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -409,8 +409,9 @@ struct CellRenderable { impl Renderable for CellRenderable { fn render(&self, area: Rect, buf: &mut Buffer) { - let p = - Paragraph::new(Text::from(self.cell.transcript_lines(area.width))).style(self.style); + let p = Paragraph::new(Text::from(self.cell.transcript_lines(area.width))) + .style(self.style) + .wrap(Wrap { trim: false }); p.render(area, buf); } @@ -645,7 +646,7 @@ impl TranscriptOverlay { has_prior_cells: bool, is_stream_continuation: bool, ) -> Box { - let paragraph = Paragraph::new(Text::from(lines)); + let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); let mut renderable: Box = Box::new(CachedRenderable::new(paragraph)); if has_prior_cells && !is_stream_continuation { renderable = Box::new(InsetRenderable::new(renderable, Insets::tlbr(1, 0, 0, 0))); diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap index 27649a11d6..3c5a1526b9 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap @@ -5,8 +5,8 @@ expression: rendered • Updated Plan └ I’ll update Grafana call error handling by adding - retries and clearer - messages when the backend is + retries and clearer messages + when the backend is unreachable. ✔ Investigate existing error paths and logging around diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index bf55c0ace1..acfa604e97 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -41,7 +41,7 @@ use super::rate_limits::compose_rate_limit_data_many; use super::rate_limits::format_status_limit_summary; use super::rate_limits::render_status_limit_progress_bar; use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_lines; +use crate::wrapping::adaptive_wrap_lines; use codex_core::AuthManager; #[derive(Debug, Clone)] @@ -479,7 +479,7 @@ impl HistoryCell for StatusHistoryCell { let note_second_line = Line::from(vec![ Span::from("information on rate limits and credits").cyan(), ]); - let note_lines = word_wrap_lines( + let note_lines = adaptive_wrap_lines( [note_first_line, note_second_line], RtOptions::new(available_inner_width), ); diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index c29106651d..f042fbbc9c 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -1,3 +1,31 @@ +//! Word-wrapping with URL-aware heuristics. +//! +//! The TUI renders text that frequently contains URLs — command output, +//! markdown, agent messages, tool-call results. Standard `textwrap` +//! hyphenation treats `/` and `-` as split points, which breaks URLs +//! across lines and makes them unclickable in terminal emulators. +//! +//! This module provides two wrapping paths: +//! +//! - **Standard** (`word_wrap_line`, `word_wrap_lines`): delegates to +//! `textwrap` with the caller's options unchanged. Used when the +//! content is known to be plain prose. +//! - **Adaptive** (`adaptive_wrap_line`, `adaptive_wrap_lines`): +//! inspects the line for URL-like tokens; if any are found, the +//! wrapping switches to `AsciiSpace` word separation and a custom +//! `WordSplitter` that refuses to split URL tokens. Non-URL tokens +//! on the same line still break at every character boundary (the +//! custom splitter returns all char indices for non-URL words). +//! +//! Callers that *might* encounter URLs should use the `adaptive_*` +//! functions. Callers that definitely will not (code blocks, pure +//! numeric output) can use the standard path for speed. +//! +//! URL detection is heuristic — see [`text_contains_url_like`] for the +//! rules. False positives suppress hyphenation for that line; false +//! negatives let a URL get split. The heuristic is intentionally +//! conservative: file paths like `src/main.rs` are not matched. + use ratatui::text::Line; use ratatui::text::Span; use std::borrow::Cow; @@ -6,12 +34,16 @@ use textwrap::Options; use crate::render::line_utils::push_owned_lines; +/// Returns byte-ranges into `text` for each wrapped line, including +/// trailing whitespace and a +1 sentinel byte. Used by the textarea +/// cursor-position logic. pub(crate) fn wrap_ranges<'a, O>(text: &str, width_or_options: O) -> Vec> where O: Into>, { let opts = width_or_options.into(); let mut lines: Vec> = Vec::new(); + let mut cursor = 0usize; for line in textwrap::wrap(text, opts).iter() { match line { std::borrow::Cow::Borrowed(slice) => { @@ -19,8 +51,14 @@ where let end = start + slice.len(); let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count(); lines.push(start..end + trailing_spaces + 1); + cursor = end + trailing_spaces; + } + std::borrow::Cow::Owned(slice) => { + let mapped = map_owned_wrapped_line_to_range(text, cursor, slice); + let trailing_spaces = text[mapped.end..].chars().take_while(|c| *c == ' ').count(); + lines.push(mapped.start..mapped.end + trailing_spaces + 1); + cursor = mapped.end + trailing_spaces; } - std::borrow::Cow::Owned(_) => panic!("wrap_ranges: unexpected owned string"), } } lines @@ -35,19 +73,429 @@ where { let opts = width_or_options.into(); let mut lines: Vec> = Vec::new(); + let mut cursor = 0usize; for line in textwrap::wrap(text, opts).iter() { match line { std::borrow::Cow::Borrowed(slice) => { let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize }; let end = start + slice.len(); lines.push(start..end); + cursor = end; + } + std::borrow::Cow::Owned(slice) => { + let mapped = map_owned_wrapped_line_to_range(text, cursor, slice); + lines.push(mapped.clone()); + cursor = mapped.end; } - std::borrow::Cow::Owned(_) => panic!("wrap_ranges_trim: unexpected owned string"), } } lines } +/// Maps an owned (materialized) wrapped line back to a byte range in `text`. +/// +/// `textwrap` returns `Cow::Owned` when it inserts a hyphenation penalty +/// character (typically `-`) that does not exist in the source. This +/// function walks the owned string character-by-character against the +/// source, skipping trailing penalty chars, and returns the +/// corresponding source byte range starting from `cursor`. +fn map_owned_wrapped_line_to_range(text: &str, cursor: usize, wrapped: &str) -> Range { + let mut start = cursor; + while start < text.len() && !wrapped.starts_with(' ') { + let Some(ch) = text[start..].chars().next() else { + break; + }; + if ch != ' ' { + break; + } + start += ch.len_utf8(); + } + + let mut end = start; + let mut chars = wrapped.chars().peekable(); + while let Some(ch) = chars.next() { + if end < text.len() { + let Some(src) = text[end..].chars().next() else { + unreachable!("checked end < text.len()"); + }; + if ch == src { + end += src.len_utf8(); + continue; + } + } + + // textwrap can materialize owned lines when penalties are inserted. + // The default penalty is a trailing '-'; it does not correspond to + // source bytes, so we skip it while keeping byte ranges in source text. + if ch == '-' && chars.peek().is_none() { + continue; + } + + panic!("wrap_ranges: could not map owned line {wrapped:?} to source near byte {cursor}"); + } + + start..end +} + +/// Returns `true` if any whitespace-delimited token in `line` looks like a URL. +/// +/// Concatenates all span contents and delegates to [`text_contains_url_like`]. +pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool { + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + text_contains_url_like(&text) +} + +/// Returns `true` if `line` contains both a URL-like token and at least one +/// substantive non-URL token. +/// +/// Decorative marker tokens (for example list prefixes like `-`, `1.`, `|`, +/// `│`) are ignored for the non-URL side of this check. +pub(crate) fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool { + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + text_has_mixed_url_and_non_url_tokens(&text) +} + +/// Returns `true` if any whitespace-delimited token in `text` looks like a URL. +/// +/// Recognized patterns: +/// - Absolute URLs with a scheme (`https://…`, `ftp://…`, custom `myapp://…`). +/// - Bare domain URLs (`example.com/path`, `www.example.com`, `localhost:3000/api`). +/// - IPv4 hosts with a path (`192.168.1.1:8080/health`). +/// +/// Surrounding punctuation (`()[]{}< >,.;:!'"`) is stripped before +/// checking. Tokens that look like file paths (`src/main.rs`, `foo/bar`) +/// are intentionally rejected — the host portion must be a valid domain +/// name (with a recognized TLD), an IPv4 address, or `localhost`. +pub(crate) fn text_contains_url_like(text: &str) -> bool { + text.split_ascii_whitespace().any(is_url_like_token) +} + +/// Returns `true` if `text` contains at least one URL-like token and at least +/// one substantive non-URL token. +fn text_has_mixed_url_and_non_url_tokens(text: &str) -> bool { + let mut saw_url = false; + let mut saw_non_url = false; + + for raw_token in text.split_ascii_whitespace() { + if is_url_like_token(raw_token) { + saw_url = true; + } else if is_substantive_non_url_token(raw_token) { + saw_non_url = true; + } + + if saw_url && saw_non_url { + return true; + } + } + + false +} + +/// Decides whether a single whitespace-delimited token is URL-like. +/// +/// Strips surrounding punctuation, then checks for an absolute URL +/// (with `://`) or a bare domain URL (recognized host + path/query/fragment). +fn is_url_like_token(raw_token: &str) -> bool { + let token = trim_url_token(raw_token); + !token.is_empty() && (is_absolute_url_like(token) || is_bare_url_like(token)) +} + +fn is_substantive_non_url_token(raw_token: &str) -> bool { + let token = trim_url_token(raw_token); + if token.is_empty() || is_decorative_marker_token(raw_token, token) { + return false; + } + + token.chars().any(char::is_alphanumeric) +} + +fn is_decorative_marker_token(raw_token: &str, token: &str) -> bool { + let raw = raw_token.trim(); + matches!( + raw, + "-" | "*" + | "+" + | "•" + | "◦" + | "▪" + | ">" + | "|" + | "│" + | "┆" + | "└" + | "├" + | "┌" + | "┐" + | "┘" + | "┼" + ) || is_ordered_list_marker(raw, token) +} + +fn is_ordered_list_marker(raw_token: &str, token: &str) -> bool { + token.chars().all(|c| c.is_ascii_digit()) + && (raw_token.ends_with('.') || raw_token.ends_with(')')) +} + +fn trim_url_token(token: &str) -> &str { + token.trim_matches(|c: char| { + matches!( + c, + '(' | ')' + | '[' + | ']' + | '{' + | '}' + | '<' + | '>' + | ',' + | '.' + | ';' + | ':' + | '!' + | '\'' + | '"' + ) + }) +} + +/// Checks for `scheme://host` patterns. Uses `url::Url::parse` for +/// well-known schemes; falls back to `has_valid_scheme_prefix` for +/// custom schemes that the `url` crate rejects. +fn is_absolute_url_like(token: &str) -> bool { + if !token.contains("://") { + return false; + } + + if let Ok(url) = url::Url::parse(token) { + let scheme = url.scheme().to_ascii_lowercase(); + if matches!( + scheme.as_str(), + "http" | "https" | "ftp" | "ftps" | "ws" | "wss" + ) { + return url.host_str().is_some(); + } + return true; + } + + has_valid_scheme_prefix(token) +} + +fn has_valid_scheme_prefix(token: &str) -> bool { + let Some((scheme, rest)) = token.split_once("://") else { + return false; + }; + if scheme.is_empty() || rest.is_empty() { + return false; + } + + let mut chars = scheme.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_alphabetic() + && chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') +} + +/// Checks for bare-domain URLs without a scheme: `host[:port]/path`, +/// `host[:port]?query`, or `host[:port]#fragment`. +/// +/// Requires that the host is `localhost`, an IPv4 address, or a valid +/// domain name. Bare `host.tld` without a path/query/fragment is only +/// accepted when the host starts with `www.`. +/// +/// IPv6 bracket notation (`[::1]:8080`) is intentionally not handled. +fn is_bare_url_like(token: &str) -> bool { + let (host_port, has_trailer) = split_host_port_and_trailer(token); + if host_port.is_empty() { + return false; + } + + // Require URL-ish trailer for bare hosts unless token starts with www. + if !has_trailer && !host_port.to_ascii_lowercase().starts_with("www.") { + return false; + } + + let (host, port) = split_host_and_port(host_port); + if host.is_empty() { + return false; + } + if let Some(port) = port + && !is_valid_port(port) + { + return false; + } + + host.eq_ignore_ascii_case("localhost") || is_ipv4(host) || is_domain_name(host) +} + +fn split_host_port_and_trailer(token: &str) -> (&str, bool) { + if let Some(idx) = token.find(['/', '?', '#']) { + (&token[..idx], true) + } else { + (token, false) + } +} + +fn split_host_and_port(host_port: &str) -> (&str, Option<&str>) { + // We intentionally do not treat bracketed IPv6 as URL-like in this first pass. + if host_port.starts_with('[') { + return (host_port, None); + } + + if let Some((host, port)) = host_port.rsplit_once(':') + && !host.is_empty() + && !port.is_empty() + && port.chars().all(|c| c.is_ascii_digit()) + { + return (host, Some(port)); + } + + (host_port, None) +} + +fn is_valid_port(port: &str) -> bool { + if port.is_empty() || port.len() > 5 || !port.chars().all(|c| c.is_ascii_digit()) { + return false; + } + + port.parse::().is_ok() +} + +fn is_ipv4(host: &str) -> bool { + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() != 4 { + return false; + } + + parts + .iter() + .all(|part| !part.is_empty() && part.parse::().is_ok()) +} + +fn is_domain_name(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + if !host.contains('.') { + return false; + } + + let mut labels = host.split('.'); + let Some(tld) = labels.next_back() else { + return false; + }; + if !is_tld(tld) { + return false; + } + + labels.all(is_domain_label) +} + +fn is_tld(label: &str) -> bool { + (2..=63).contains(&label.len()) && label.chars().all(|c| c.is_ascii_alphabetic()) +} + +fn is_domain_label(label: &str) -> bool { + if label.is_empty() || label.len() > 63 { + return false; + } + + let mut chars = label.chars(); + let Some(first) = chars.next() else { + return false; + }; + let Some(last) = label.chars().next_back() else { + return false; + }; + + first.is_ascii_alphanumeric() + && last.is_ascii_alphanumeric() + && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +/// Reconfigures wrapping options so that URL-like tokens are never split. +/// +/// Sets `AsciiSpace` word separation (so `/` and `-` inside URLs are +/// not treated as break points), disables `break_words`, and installs a +/// custom `WordSplitter` that returns no split points for URL tokens +/// while still allowing character-level splitting for non-URL words. +pub(crate) fn url_preserving_wrap_options<'a>(opts: RtOptions<'a>) -> RtOptions<'a> { + opts.word_separator(textwrap::WordSeparator::AsciiSpace) + .word_splitter(textwrap::WordSplitter::Custom(split_non_url_word)) + .break_words(false) +} + +/// Custom `textwrap::WordSplitter` callback. Returns empty (no split +/// points) for URL-like tokens so they are kept intact; returns every +/// char-boundary index for everything else so non-URL words can still +/// break at any position. +fn split_non_url_word(word: &str) -> Vec { + if is_url_like_token(word) { + return Vec::new(); + } + + word.char_indices().skip(1).map(|(idx, _)| idx).collect() +} + +/// Wraps a single ratatui `Line`, automatically switching to +/// URL-preserving options when the line contains a URL-like token. +/// +/// When no URL is detected, wrapping behavior is identical to +/// [`word_wrap_line`]. When a URL is detected, the line is wrapped with +/// [`url_preserving_wrap_options`] — URLs stay intact while non-URL +/// words on the same line still break normally. +#[must_use] +pub(crate) fn adaptive_wrap_line<'a>(line: &'a Line<'a>, base: RtOptions<'a>) -> Vec> { + let selected = if line_contains_url_like(line) { + url_preserving_wrap_options(base) + } else { + base + }; + word_wrap_line(line, selected) +} + +/// Wraps multiple input lines with URL-aware heuristics, applying +/// `initial_indent` to the first line and `subsequent_indent` to the +/// rest. Each line is independently checked for URLs; URL detection on +/// one line does not affect wrapping of the others. +/// +/// This is the multi-line counterpart to [`adaptive_wrap_line`] and is +/// the primary wrapping entry point for most history-cell rendering. +#[allow(private_bounds)] +pub(crate) fn adaptive_wrap_lines<'a, I, L>( + lines: I, + width_or_options: RtOptions<'a>, +) -> Vec> +where + I: IntoIterator, + L: IntoLineInput<'a>, +{ + let base_opts = width_or_options; + let mut out: Vec> = Vec::new(); + + for (idx, line) in lines.into_iter().enumerate() { + let line_input = line.into_line_input(); + let opts = if idx == 0 { + base_opts.clone() + } else { + base_opts + .clone() + .initial_indent(base_opts.subsequent_indent.clone()) + }; + + let wrapped = adaptive_wrap_line(line_input.as_ref(), opts); + push_owned_lines(&wrapped, &mut out); + } + + out +} + #[derive(Debug, Clone)] pub struct RtOptions<'a> { /// The width in columns at which the text will be wrapped. @@ -644,4 +1092,162 @@ the kindness of the woman who tended them."# ); } + + #[test] + fn ascii_space_separator_with_no_hyphenation_keeps_url_intact() { + let line = Line::from( + "http://example.com/long-url-with-dashes-wider-than-terminal-window/blah-blah-blah-text/more-gibberish-text", + ); + let opts = RtOptions::new(24) + .word_separator(textwrap::WordSeparator::AsciiSpace) + .word_splitter(textwrap::WordSplitter::NoHyphenation) + .break_words(false); + + let out = word_wrap_line(&line, opts); + + assert_eq!(out.len(), 1); + assert_eq!( + concat_line(&out[0]), + "http://example.com/long-url-with-dashes-wider-than-terminal-window/blah-blah-blah-text/more-gibberish-text" + ); + } + + #[test] + fn text_contains_url_like_matches_expected_tokens() { + let positives = [ + "https://example.com/a/b", + "ftp://host/path", + "www.example.com/path?x=1", + "example.test/path#frag", + "localhost:3000/api", + "127.0.0.1:8080/health", + "(https://example.com/wrapped-in-parens)", + ]; + + for text in positives { + assert!( + text_contains_url_like(text), + "expected URL-like match for {text:?}" + ); + } + } + + #[test] + fn text_contains_url_like_rejects_non_urls() { + let negatives = [ + "src/main.rs", + "foo/bar", + "key:value", + "just-some-text-with-dashes", + "hello.world", // no path/query/fragment and no www + ]; + + for text in negatives { + assert!( + !text_contains_url_like(text), + "did not expect URL-like match for {text:?}" + ); + } + } + + #[test] + fn line_contains_url_like_checks_across_spans() { + let line = Line::from(vec![ + "see ".into(), + "https://example.com/a/very/long/path".cyan(), + " for details".into(), + ]); + + assert!(line_contains_url_like(&line)); + } + + #[test] + fn line_has_mixed_url_and_non_url_tokens_detects_prose_plus_url() { + let line = Line::from("see https://example.com/path for details"); + assert!(line_has_mixed_url_and_non_url_tokens(&line)); + } + + #[test] + fn line_has_mixed_url_and_non_url_tokens_ignores_pipe_prefix() { + let line = Line::from(vec![" │ ".into(), "https://example.com/path".into()]); + assert!(!line_has_mixed_url_and_non_url_tokens(&line)); + } + + #[test] + fn line_has_mixed_url_and_non_url_tokens_ignores_ordered_list_marker() { + let line = Line::from("1. https://example.com/path"); + assert!(!line_has_mixed_url_and_non_url_tokens(&line)); + } + + #[test] + fn text_contains_url_like_accepts_custom_scheme_with_separator() { + assert!(text_contains_url_like("myapp://open/some/path")); + } + + #[test] + fn text_contains_url_like_rejects_invalid_ports() { + assert!(!text_contains_url_like("localhost:99999/path")); + assert!(!text_contains_url_like("example.com:abc/path")); + } + + #[test] + fn adaptive_wrap_line_keeps_long_url_like_token_intact() { + let line = Line::from("example.test/a-very-long-path-with-many-segments-and-query?x=1&y=2"); + let out = adaptive_wrap_line(&line, RtOptions::new(20)); + assert_eq!(out.len(), 1); + assert_eq!( + concat_line(&out[0]), + "example.test/a-very-long-path-with-many-segments-and-query?x=1&y=2" + ); + } + + #[test] + fn adaptive_wrap_line_preserves_default_behavior_for_non_url_tokens() { + let line = Line::from("a_very_long_token_without_spaces_to_force_wrapping"); + let out = adaptive_wrap_line(&line, RtOptions::new(20)); + assert!( + out.len() > 1, + "expected non-url token to wrap with default options" + ); + } + + #[test] + fn adaptive_wrap_line_mixed_line_wraps_long_non_url_token() { + let long_non_url = "a_very_long_token_without_spaces_to_force_wrapping"; + let line = Line::from(format!("see https://ex.com {long_non_url}")); + let out = adaptive_wrap_line(&line, RtOptions::new(24)); + + assert!( + out.iter() + .any(|line| concat_line(line).contains("https://ex.com")), + "expected URL token to remain present, got: {out:?}" + ); + assert!( + !out.iter() + .any(|line| concat_line(line).contains(long_non_url)), + "expected long non-url token to wrap on mixed lines, got: {out:?}" + ); + } + + #[test] + fn wrap_ranges_trim_handles_owned_lines_with_penalty_char() { + fn split_every_char(word: &str) -> Vec { + word.char_indices().skip(1).map(|(idx, _)| idx).collect() + } + + let text = "a_very_long_token_without_spaces"; + let opts = Options::new(8) + .word_separator(textwrap::WordSeparator::AsciiSpace) + .word_splitter(textwrap::WordSplitter::Custom(split_every_char)) + .break_words(false); + + let ranges = wrap_ranges_trim(text, opts); + let rebuilt = ranges + .iter() + .map(|range| &text[range.clone()]) + .collect::(); + + assert_eq!(rebuilt, text); + assert!(ranges.len() > 1, "expected wrapped ranges, got: {ranges:?}"); + } } From 031d7017055f5142eba0b828a6529296ee7d6492 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 21 Feb 2026 15:45:27 -0800 Subject: [PATCH 045/282] Route inbound realtime text into turn start or steer (#12469) - Route inbound realtime websocket text into normal user input handling so it steers an active turn or starts a new one --- codex-rs/core/src/codex.rs | 15 + codex-rs/core/src/realtime_conversation.rs | 88 +++++- .../core/tests/suite/realtime_conversation.rs | 264 ++++++++++++++++-- 3 files changed, 350 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 832592d406..45dd4f2b6e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1518,6 +1518,21 @@ impl Session { format!("auto-compact-{id}") } + pub(crate) async fn route_realtime_text_input(self: &Arc, text: String) { + handlers::user_input_or_turn( + self, + self.next_internal_sub_id(), + Op::UserInput { + items: vec![UserInput::Text { + text, + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }, + ) + .await; + } + pub(crate) async fn get_total_token_usage(&self) -> i64 { let state = self.state.lock().await; state.get_total_token_usage(state.server_reasoning_included()) diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index b67874593f..b1540ca944 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -25,6 +25,7 @@ use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; use http::HeaderMap; +use serde_json::Value; use std::sync::Arc; use tokio::sync::Mutex; use tokio::task::JoinHandle; @@ -209,11 +210,22 @@ pub(crate) async fn handle_start( msg, }; while let Ok(event) = events_rx.recv().await { + let maybe_routed_text = match &event { + RealtimeEvent::ConversationItemAdded(item) => { + realtime_text_from_conversation_item(item) + } + _ => None, + }; sess_clone .send_event_raw(ev(EventMsg::RealtimeConversationRealtime( - RealtimeConversationRealtimeEvent { payload: event }, + RealtimeConversationRealtimeEvent { + payload: event.clone(), + }, ))) .await; + if let Some(text) = maybe_routed_text { + sess_clone.route_realtime_text_input(text).await; + } } if let Some(()) = sess_clone.conversation.running_state().await { sess_clone @@ -239,6 +251,19 @@ pub(crate) async fn handle_audio( } } +fn realtime_text_from_conversation_item(item: &Value) -> Option { + if item.get("type").and_then(Value::as_str) != Some("message") { + return None; + } + let content = item.get("content")?.as_array()?; + let text = content + .iter() + .filter(|entry| entry.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|entry| entry.get("text").and_then(Value::as_str)) + .collect::(); + if text.is_empty() { None } else { Some(text) } +} + pub(crate) async fn handle_text( sess: &Arc, sub_id: String, @@ -355,3 +380,64 @@ async fn send_conversation_error( }) .await; } + +#[cfg(test)] +mod tests { + use super::realtime_text_from_conversation_item; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn extracts_text_from_message_items_ignoring_role() { + let assistant = json!({ + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "hello"}], + }); + assert_eq!( + realtime_text_from_conversation_item(&assistant), + Some("hello".to_string()) + ); + + let user = json!({ + "type": "message", + "role": "user", + "content": [{"type": "text", "text": "world"}], + }); + assert_eq!( + realtime_text_from_conversation_item(&user), + Some("world".to_string()) + ); + } + + #[test] + fn extracts_and_concatenates_text_entries_only() { + let item = json!({ + "type": "message", + "content": [ + {"type": "text", "text": "a"}, + {"type": "ignored", "text": "x"}, + {"type": "text", "text": "b"} + ], + }); + assert_eq!( + realtime_text_from_conversation_item(&item), + Some("ab".to_string()) + ); + } + + #[test] + fn ignores_non_message_or_missing_text() { + let non_message = json!({ + "type": "tool_call", + "content": [{"type": "text", "text": "nope"}], + }); + assert_eq!(realtime_text_from_conversation_item(&non_message), None); + + let no_text = json!({ + "type": "message", + "content": [{"type": "other", "value": 1}], + }); + assert_eq!(realtime_text_from_conversation_item(&no_text), None); + } +} diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 466686788d..ee4f139f60 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -9,13 +9,21 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeEvent; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::responses::start_mock_server; use core_test_support::responses::start_websocket_server; use core_test_support::skip_if_no_network; +use core_test_support::streaming_sse::StreamingSseChunk; +use core_test_support::streaming_sse::start_streaming_sse_server; use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; +use serde_json::Value; use serde_json::json; use std::time::Duration; +use tokio::sync::oneshot; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_start_audio_text_close_round_trip() -> Result<()> { @@ -29,22 +37,12 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { "session": { "id": "sess_1" } })], vec![], - vec![ - json!({ - "type": "response.output_audio.delta", - "delta": "AQID", - "sample_rate": 24000, - "num_channels": 1 - }), - json!({ - "type": "conversation.item.added", - "item": { - "type": "message", - "role": "assistant", - "content": [{"type": "text", "text": "hi"}] - } - }), - ], + vec![json!({ + "type": "response.output_audio.delta", + "delta": "AQID", + "sample_rate": 24000, + "num_channels": 1 + })], ], ]) .await; @@ -458,3 +456,237 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> server.shutdown().await; Ok(()) } + +fn sse_event(event: Value) -> String { + responses::sse(vec![event]) +} + +fn message_input_texts(body: &Value, role: &str) -> Vec { + body.get("input") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("message")) + .filter(|item| item.get("role").and_then(Value::as_str) == Some(role)) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text")) + .filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned)) + .collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inbound_realtime_text_starts_turn_and_ignores_role() -> Result<()> { + skip_if_no_network!(Ok(())); + + let api_server = start_mock_server().await; + let response_mock = responses::mount_sse_once( + &api_server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "ok"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let realtime_server = start_websocket_server(vec![vec![vec![ + json!({ + "type": "session.created", + "session": { "id": "sess_inbound" } + }), + json!({ + "type": "conversation.item.added", + "item": { + "type": "message", + "role": "user", + "content": [{"type": "text", "text": "text from realtime"}] + } + }), + ]]]) + .await; + + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let session_created = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) => Some(session_id.clone()), + _ => None, + }) + .await; + assert_eq!(session_created, "sess_inbound"); + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let request = response_mock.single_request(); + let user_texts = request.message_input_texts("user"); + assert!(user_texts.iter().any(|text| text == "text from realtime")); + + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inbound_realtime_text_steers_active_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (gate_completed_tx, gate_completed_rx) = oneshot::channel(); + let first_chunks = vec![ + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_response_created("resp-1")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_message_item_added("msg-1", "")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_output_text_delta("first ")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_output_text_delta("turn")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_assistant_message("msg-1", "first turn")), + }, + StreamingSseChunk { + gate: Some(gate_completed_rx), + body: sse_event(responses::ev_completed("resp-1")), + }, + ]; + let second_chunks = vec![ + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_response_created("resp-2")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_completed("resp-2")), + }, + ]; + let (api_server, _completions) = + start_streaming_sse_server(vec![first_chunks, second_chunks]).await; + + let realtime_server = start_websocket_server(vec![vec![ + vec![json!({ + "type": "session.created", + "session": { "id": "sess_steer" } + })], + vec![json!({ + "type": "conversation.item.added", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "steer via realtime"}] + } + })], + ]]) + .await; + + let mut builder = test_codex().with_model("gpt-5.1").with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_streaming_server(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) if session_id == "sess_steer" => Some(()), + _ => None, + }) + .await; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "first prompt".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::AgentMessageContentDelta(_)) + }) + .await; + + test.codex + .submit(Op::RealtimeConversationAudio(ConversationAudioParams { + frame: RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 24000, + num_channels: 1, + samples_per_channel: Some(480), + }, + })) + .await?; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ConversationItemAdded(item), + }) => item + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|content| { + content.get("text").and_then(Value::as_str) == Some("steer via realtime") + }) + .then_some(()), + _ => None, + }) + .await; + + let _ = gate_completed_tx.send(()); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = api_server.requests().await; + assert_eq!(requests.len(), 2); + + let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request"); + let second_body: Value = serde_json::from_slice(&requests[1]).expect("parse second request"); + let first_texts = message_input_texts(&first_body, "user"); + let second_texts = message_input_texts(&second_body, "user"); + + assert!(first_texts.iter().any(|text| text == "first prompt")); + assert!(!first_texts.iter().any(|text| text == "steer via realtime")); + assert!(second_texts.iter().any(|text| text == "first prompt")); + assert!(second_texts.iter().any(|text| text == "steer via realtime")); + + realtime_server.shutdown().await; + api_server.shutdown().await; + Ok(()) +} From 5e505ff8773649162126df9e8b67140e3e435672 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 21 Feb 2026 15:46:03 -0800 Subject: [PATCH 046/282] Revert "Route inbound realtime text into turn start or steer" (#12479) Reverts openai/codex#12469 --- codex-rs/core/src/codex.rs | 15 - codex-rs/core/src/realtime_conversation.rs | 88 +----- .../core/tests/suite/realtime_conversation.rs | 264 ++---------------- 3 files changed, 17 insertions(+), 350 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 45dd4f2b6e..832592d406 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1518,21 +1518,6 @@ impl Session { format!("auto-compact-{id}") } - pub(crate) async fn route_realtime_text_input(self: &Arc, text: String) { - handlers::user_input_or_turn( - self, - self.next_internal_sub_id(), - Op::UserInput { - items: vec![UserInput::Text { - text, - text_elements: Vec::new(), - }], - final_output_json_schema: None, - }, - ) - .await; - } - pub(crate) async fn get_total_token_usage(&self) -> i64 { let state = self.state.lock().await; state.get_total_token_usage(state.server_reasoning_included()) diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index b1540ca944..b67874593f 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -25,7 +25,6 @@ use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; use http::HeaderMap; -use serde_json::Value; use std::sync::Arc; use tokio::sync::Mutex; use tokio::task::JoinHandle; @@ -210,22 +209,11 @@ pub(crate) async fn handle_start( msg, }; while let Ok(event) = events_rx.recv().await { - let maybe_routed_text = match &event { - RealtimeEvent::ConversationItemAdded(item) => { - realtime_text_from_conversation_item(item) - } - _ => None, - }; sess_clone .send_event_raw(ev(EventMsg::RealtimeConversationRealtime( - RealtimeConversationRealtimeEvent { - payload: event.clone(), - }, + RealtimeConversationRealtimeEvent { payload: event }, ))) .await; - if let Some(text) = maybe_routed_text { - sess_clone.route_realtime_text_input(text).await; - } } if let Some(()) = sess_clone.conversation.running_state().await { sess_clone @@ -251,19 +239,6 @@ pub(crate) async fn handle_audio( } } -fn realtime_text_from_conversation_item(item: &Value) -> Option { - if item.get("type").and_then(Value::as_str) != Some("message") { - return None; - } - let content = item.get("content")?.as_array()?; - let text = content - .iter() - .filter(|entry| entry.get("type").and_then(Value::as_str) == Some("text")) - .filter_map(|entry| entry.get("text").and_then(Value::as_str)) - .collect::(); - if text.is_empty() { None } else { Some(text) } -} - pub(crate) async fn handle_text( sess: &Arc, sub_id: String, @@ -380,64 +355,3 @@ async fn send_conversation_error( }) .await; } - -#[cfg(test)] -mod tests { - use super::realtime_text_from_conversation_item; - use pretty_assertions::assert_eq; - use serde_json::json; - - #[test] - fn extracts_text_from_message_items_ignoring_role() { - let assistant = json!({ - "type": "message", - "role": "assistant", - "content": [{"type": "text", "text": "hello"}], - }); - assert_eq!( - realtime_text_from_conversation_item(&assistant), - Some("hello".to_string()) - ); - - let user = json!({ - "type": "message", - "role": "user", - "content": [{"type": "text", "text": "world"}], - }); - assert_eq!( - realtime_text_from_conversation_item(&user), - Some("world".to_string()) - ); - } - - #[test] - fn extracts_and_concatenates_text_entries_only() { - let item = json!({ - "type": "message", - "content": [ - {"type": "text", "text": "a"}, - {"type": "ignored", "text": "x"}, - {"type": "text", "text": "b"} - ], - }); - assert_eq!( - realtime_text_from_conversation_item(&item), - Some("ab".to_string()) - ); - } - - #[test] - fn ignores_non_message_or_missing_text() { - let non_message = json!({ - "type": "tool_call", - "content": [{"type": "text", "text": "nope"}], - }); - assert_eq!(realtime_text_from_conversation_item(&non_message), None); - - let no_text = json!({ - "type": "message", - "content": [{"type": "other", "value": 1}], - }); - assert_eq!(realtime_text_from_conversation_item(&no_text), None); - } -} diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index ee4f139f60..466686788d 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -9,21 +9,13 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeEvent; -use codex_protocol::user_input::UserInput; -use core_test_support::responses; -use core_test_support::responses::start_mock_server; use core_test_support::responses::start_websocket_server; use core_test_support::skip_if_no_network; -use core_test_support::streaming_sse::StreamingSseChunk; -use core_test_support::streaming_sse::start_streaming_sse_server; use core_test_support::test_codex::test_codex; -use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; -use serde_json::Value; use serde_json::json; use std::time::Duration; -use tokio::sync::oneshot; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_start_audio_text_close_round_trip() -> Result<()> { @@ -37,12 +29,22 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { "session": { "id": "sess_1" } })], vec![], - vec![json!({ - "type": "response.output_audio.delta", - "delta": "AQID", - "sample_rate": 24000, - "num_channels": 1 - })], + vec![ + json!({ + "type": "response.output_audio.delta", + "delta": "AQID", + "sample_rate": 24000, + "num_channels": 1 + }), + json!({ + "type": "conversation.item.added", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "hi"}] + } + }), + ], ], ]) .await; @@ -456,237 +458,3 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> server.shutdown().await; Ok(()) } - -fn sse_event(event: Value) -> String { - responses::sse(vec![event]) -} - -fn message_input_texts(body: &Value, role: &str) -> Vec { - body.get("input") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter(|item| item.get("type").and_then(Value::as_str) == Some("message")) - .filter(|item| item.get("role").and_then(Value::as_str) == Some(role)) - .filter_map(|item| item.get("content").and_then(Value::as_array)) - .flatten() - .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text")) - .filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned)) - .collect() -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn inbound_realtime_text_starts_turn_and_ignores_role() -> Result<()> { - skip_if_no_network!(Ok(())); - - let api_server = start_mock_server().await; - let response_mock = responses::mount_sse_once( - &api_server, - responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_assistant_message("msg-1", "ok"), - responses::ev_completed("resp-1"), - ]), - ) - .await; - - let realtime_server = start_websocket_server(vec![vec![vec![ - json!({ - "type": "session.created", - "session": { "id": "sess_inbound" } - }), - json!({ - "type": "conversation.item.added", - "item": { - "type": "message", - "role": "user", - "content": [{"type": "text", "text": "text from realtime"}] - } - }), - ]]]) - .await; - - let mut builder = test_codex().with_config({ - let realtime_base_url = realtime_server.uri().to_string(); - move |config| { - config.experimental_realtime_ws_base_url = Some(realtime_base_url); - } - }); - let test = builder.build(&api_server).await?; - - test.codex - .submit(Op::RealtimeConversationStart(ConversationStartParams { - prompt: "backend prompt".to_string(), - session_id: None, - })) - .await?; - - let session_created = wait_for_event_match(&test.codex, |msg| match msg { - EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, - }) => Some(session_id.clone()), - _ => None, - }) - .await; - assert_eq!(session_created, "sess_inbound"); - - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - - let request = response_mock.single_request(); - let user_texts = request.message_input_texts("user"); - assert!(user_texts.iter().any(|text| text == "text from realtime")); - - realtime_server.shutdown().await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn inbound_realtime_text_steers_active_turn() -> Result<()> { - skip_if_no_network!(Ok(())); - - let (gate_completed_tx, gate_completed_rx) = oneshot::channel(); - let first_chunks = vec![ - StreamingSseChunk { - gate: None, - body: sse_event(responses::ev_response_created("resp-1")), - }, - StreamingSseChunk { - gate: None, - body: sse_event(responses::ev_message_item_added("msg-1", "")), - }, - StreamingSseChunk { - gate: None, - body: sse_event(responses::ev_output_text_delta("first ")), - }, - StreamingSseChunk { - gate: None, - body: sse_event(responses::ev_output_text_delta("turn")), - }, - StreamingSseChunk { - gate: None, - body: sse_event(responses::ev_assistant_message("msg-1", "first turn")), - }, - StreamingSseChunk { - gate: Some(gate_completed_rx), - body: sse_event(responses::ev_completed("resp-1")), - }, - ]; - let second_chunks = vec![ - StreamingSseChunk { - gate: None, - body: sse_event(responses::ev_response_created("resp-2")), - }, - StreamingSseChunk { - gate: None, - body: sse_event(responses::ev_completed("resp-2")), - }, - ]; - let (api_server, _completions) = - start_streaming_sse_server(vec![first_chunks, second_chunks]).await; - - let realtime_server = start_websocket_server(vec![vec![ - vec![json!({ - "type": "session.created", - "session": { "id": "sess_steer" } - })], - vec![json!({ - "type": "conversation.item.added", - "item": { - "type": "message", - "role": "assistant", - "content": [{"type": "text", "text": "steer via realtime"}] - } - })], - ]]) - .await; - - let mut builder = test_codex().with_model("gpt-5.1").with_config({ - let realtime_base_url = realtime_server.uri().to_string(); - move |config| { - config.experimental_realtime_ws_base_url = Some(realtime_base_url); - } - }); - let test = builder.build_with_streaming_server(&api_server).await?; - - test.codex - .submit(Op::RealtimeConversationStart(ConversationStartParams { - prompt: "backend prompt".to_string(), - session_id: None, - })) - .await?; - let _ = wait_for_event_match(&test.codex, |msg| match msg { - EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::SessionCreated { session_id }, - }) if session_id == "sess_steer" => Some(()), - _ => None, - }) - .await; - - test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "first prompt".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - }) - .await?; - - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::AgentMessageContentDelta(_)) - }) - .await; - - test.codex - .submit(Op::RealtimeConversationAudio(ConversationAudioParams { - frame: RealtimeAudioFrame { - data: "AQID".to_string(), - sample_rate: 24000, - num_channels: 1, - samples_per_channel: Some(480), - }, - })) - .await?; - - let _ = wait_for_event_match(&test.codex, |msg| match msg { - EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::ConversationItemAdded(item), - }) => item - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .any(|content| { - content.get("text").and_then(Value::as_str) == Some("steer via realtime") - }) - .then_some(()), - _ => None, - }) - .await; - - let _ = gate_completed_tx.send(()); - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - - let requests = api_server.requests().await; - assert_eq!(requests.len(), 2); - - let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request"); - let second_body: Value = serde_json::from_slice(&requests[1]).expect("parse second request"); - let first_texts = message_input_texts(&first_body, "user"); - let second_texts = message_input_texts(&second_body, "user"); - - assert!(first_texts.iter().any(|text| text == "first prompt")); - assert!(!first_texts.iter().any(|text| text == "steer via realtime")); - assert!(second_texts.iter().any(|text| text == "first prompt")); - assert!(second_texts.iter().any(|text| text == "steer via realtime")); - - realtime_server.shutdown().await; - api_server.shutdown().await; - Ok(()) -} From b73c4b50a27e2acdbe80875b4bfba20666889e28 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 21 Feb 2026 17:06:35 -0800 Subject: [PATCH 047/282] fix: make realtime conversation flake test order-insensitive (#12475) ## Why `codex-core::all` has a flaky test, `suite::realtime_conversation::conversation_start_audio_text_close_round_trip`, that assumes a fixed ordering between `conversation.item.create` and `response.input_audio.delta` requests. That ordering is not guaranteed: realtime text and audio input are forwarded through separate queues and a background task, so either request can be observed first while still being correct behavior. ## What Changed - Updated the assertion in `codex-rs/core/tests/suite/realtime_conversation.rs` to compare the two observed request types order-independently. - Kept the existing checks that `session.create` is sent first and that exactly two follow-up requests are recorded. ## Verification - Re-ran `cargo test -p codex-core --test all conversation_start_audio_text_close_round_trip` 10 times locally. --- codex-rs/core/tests/suite/realtime_conversation.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 466686788d..fc7b21e0e8 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -120,7 +120,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { .as_deref() .expect("started session id should be present") ); - let request_types = [ + let mut request_types = [ connection[1].body_json()["type"] .as_str() .expect("request type") @@ -130,6 +130,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { .expect("request type") .to_string(), ]; + request_types.sort(); assert_eq!( request_types, [ From 1dad0a7f4ac80fec91b36981d1cf3cb58326be9f Mon Sep 17 00:00:00 2001 From: Alex Kwiatkowski Date: Sat, 21 Feb 2026 20:08:02 -0800 Subject: [PATCH 048/282] Make shell detection tests robust to Nix shell paths (#12476) ## Summary - Updated `codex-rs/core/src/shell.rs` tests for shell detection to stop asserting hardcoded shell paths. - `detects_bash` and `detects_sh` now assert executable basenames (`bash`, `sh`) rather than `/bin/*`/`/usr/bin/*` absolute paths. - This keeps behavior the same while avoiding failures in Nix environments where shells are resolved from `/nix/store/.../bin`. ## Testing - `nix develop .#default --command sh -lc 'export PKG_CONFIG_PATH=/nix/store/6az1q591wwlgazzskngr6rl7gmhpyvnc-libcap-2.77-dev/lib/pkgconfig:/nix/store/fgm3pz8486ksh3f94629lpb7xjr2wjp7-openssl-3.6.0-dev/lib/pkgconfig:$PKG_CONFIG_PATH; export PKG_CONFIG_PATH_FOR_TARGET=$PKG_CONFIG_PATH; cd /home/alex/workspace/openai/codex/codex-rs && cargo test -p codex-core --lib detects_bash && cargo test -p codex-core --lib detects_sh'` ## Why The two failing tests previously hardcoded fixed paths and failed under the Nix shell due to Nix-provided shell binary locations. ## Links - Bug report / enhancement request: not publicly filed yet; this was reproduced in the local Nix environment. --- codex-rs/core/src/shell.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index e4a1faf791..cec5d8a93a 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -343,7 +343,6 @@ mod detect_shell_type_tests { #[cfg(unix)] mod tests { use super::*; - use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -354,7 +353,7 @@ mod tests { let shell_path = zsh_shell.shell_path; - assert_eq!(shell_path, Path::new("/bin/zsh")); + assert_eq!(shell_path, std::path::Path::new("/bin/zsh")); } #[test] @@ -364,7 +363,7 @@ mod tests { let shell_path = zsh_shell.shell_path; - assert_eq!(shell_path, Path::new("/bin/zsh")); + assert_eq!(shell_path, std::path::Path::new("/bin/zsh")); } #[test] @@ -373,9 +372,7 @@ mod tests { let shell_path = bash_shell.shell_path; assert!( - shell_path == Path::new("/bin/bash") - || shell_path == Path::new("/usr/bin/bash") - || shell_path == Path::new("/usr/local/bin/bash"), + shell_path.file_name().and_then(|name| name.to_str()) == Some("bash"), "shell path: {shell_path:?}", ); } @@ -385,7 +382,7 @@ mod tests { let sh_shell = get_shell(ShellType::Sh, None).unwrap(); let shell_path = sh_shell.shell_path; assert!( - shell_path == Path::new("/bin/sh") || shell_path == Path::new("/usr/bin/sh"), + shell_path.file_name().and_then(|name| name.to_str()) == Some("sh"), "shell path: {shell_path:?}", ); } From c4f1af7a86a08d52a5bbbd27dcb5314c779827db Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 22 Feb 2026 01:26:58 -0300 Subject: [PATCH 049/282] feat(tui): syntax highlighting via syntect with theme picker (#11447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds syntax highlighting to the TUI for fenced code blocks in markdown responses and file diffs, plus a `/theme` command with live preview and persistent theme selection. Uses syntect (~250 grammars, 32 bundled themes, ~1 MB binary cost) — the same engine behind `bat`, `delta`, and `xi-editor`. Includes guardrails for large inputs, graceful fallback to plain text, and SSH-aware clipboard integration for the `/copy` command. image image ## Problem Code blocks in the TUI (markdown responses and file diffs) render without syntax highlighting, making it hard to scan code at a glance. Users also have no way to pick a color theme that matches their terminal aesthetic. ## Mental model The highlighting system has three layers: 1. **Syntax engine** (`render::highlight`) -- a thin wrapper around syntect + two-face. It owns a process-global `SyntaxSet` (~250 grammars) and a `RwLock` that can be swapped at runtime. All public entry points accept `(code, lang)` and return ratatui `Span`/`Line` vectors or `None` when the language is unrecognized or the input exceeds safety guardrails. 2. **Rendering consumers** -- `markdown_render` feeds fenced code blocks through the engine; `diff_render` highlights Add/Delete content as a whole file and Update hunks per-hunk (preserving parser state across hunk lines). Both callers fall back to plain unstyled text when the engine returns `None`. 3. **Theme lifecycle** -- at startup the config's `tui.theme` is resolved to a syntect `Theme` via `set_theme_override`. At runtime the `/theme` picker calls `set_syntax_theme` to swap themes live; on cancel it restores the snapshot taken at open. On confirm it persists `[tui] theme = "..."` to config.toml. ## Non-goals - Inline diff highlighting (word-level change detection within a line). - Semantic / LSP-backed highlighting. - Theme authoring tooling; users supply standard `.tmTheme` files. ## Tradeoffs | Decision | Upside | Downside | | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | syntect over tree-sitter / arborium | ~1 MB binary increase for ~250 grammars + 32 themes; battle-tested crate powering widely-used tools (`bat`, `delta`, `xi-editor`). tree-sitter would add ~12 MB for 20-30 languages or ~35 MB for full coverage. | Regex-based; less structurally accurate than tree-sitter for some languages (e.g. language injections like JS-in-HTML). | | Global `RwLock` | Enables live `/theme` preview without threading Theme through every call site | Lock contention risk (mitigated: reads vastly outnumber writes, single UI thread) | | Skip background / italic / underline from themes | Terminal BG preserved, avoids ugly rendering on some themes | Themes that rely on these properties lose fidelity | | Guardrails: 512 KB / 10k lines | Prevents pathological stalls on huge diffs or pastes | Very large files render without color | ## Architecture ``` config.toml ─[tui.theme]─> set_theme_override() ─> THEME (RwLock) │ ┌───────────────────────────────────────────┘ │ markdown_render ─── highlight_code_to_lines(code, lang) ─> Vec diff_render ─── highlight_code_to_styled_spans(code, lang) ─> Option>> │ │ (None ⇒ plain text fallback) │ /theme picker ─── set_syntax_theme(theme) // live preview swap ─── current_syntax_theme() // snapshot for cancel ─── resolve_theme_by_name(name) // lookup by kebab-case ``` Key files: - `tui/src/render/highlight.rs` -- engine, theme management, guardrails - `tui/src/diff_render.rs` -- syntax-aware diff line wrapping - `tui/src/theme_picker.rs` -- `/theme` command builder - `tui/src/bottom_pane/list_selection_view.rs` -- side content panel, callbacks - `core/src/config/types.rs` -- `Tui::theme` field - `core/src/config/edit.rs` -- `syntax_theme_edit()` helper ## Observability - `tracing::warn` when a configured theme name cannot be resolved. - `Config::startup_warnings` surfaces the same message as a TUI banner. - `tracing::error` when persisting theme selection fails. ## Tests - Unit tests in `highlight.rs`: language coverage, fallback behavior, CRLF stripping, style conversion, guardrail enforcement, theme name mapping exhaustiveness. - Unit tests in `diff_render.rs`: snapshot gallery at multiple terminal sizes (80x24, 94x35, 120x40), syntax-highlighted wrapping, large-diff guardrail, rename-to-different-extension highlighting, parser state preservation across hunk lines. - Unit tests in `theme_picker.rs`: preview rendering (wide + narrow), dim overlay on deletions, subtitle truncation, cancel-restore, fallback for unavailable configured theme. - Unit tests in `list_selection_view.rs`: side layout geometry, stacked fallback, buffer clearing, cancel/selection-changed callbacks. - Integration test in `lib.rs`: theme warning uses the final (post-resume) config. ## Cargo Deny: Unmaintained Dependency Exceptions This PR adds two `cargo deny` advisory exceptions for transitive dependencies pulled in by `syntect v5.3.0`: | Advisory | Crate | Status | |----------|-------|--------| | RUSTSEC-2024-0320 | `yaml-rust` | Unmaintained (maintainer unreachable) | | RUSTSEC-2025-0141 | `bincode` | Unmaintained (development ceased; v1.3.3 considered complete) | **Why this is safe in our usage:** - Neither advisory describes a known security vulnerability. Both are "unmaintained" notices only. - `bincode` is used by syntect to deserialize pre-compiled syntax sets. Again, these are **static vendored artifacts** baked into the binary at build time. No user-supplied bincode data is ever deserialized. - Attack surface is zero for both crates; exploitation would require a supply-chain compromise of our own build artifacts. - These exceptions can be removed when syntect migrates to `yaml-rust2` and drops `bincode`, or when alternative crates are available upstream. --- codex-rs/Cargo.lock | 107 +- codex-rs/Cargo.toml | 2 +- codex-rs/core/config.schema.json | 5 + codex-rs/core/src/config/edit.rs | 8 + codex-rs/core/src/config/mod.rs | 33 + codex-rs/core/src/config/types.rs | 7 + codex-rs/deny.toml | 3 + codex-rs/tui/Cargo.toml | 4 +- codex-rs/tui/src/app.rs | 64 + codex-rs/tui/src/app_event.rs | 5 + .../src/bottom_pane/list_selection_view.rs | 625 ++++++++- codex-rs/tui/src/bottom_pane/mod.rs | 3 + codex-rs/tui/src/chatwidget.rs | 22 + ...dget__tests__exec_approval_modal_exec.snap | 3 + codex-rs/tui/src/diff_render.rs | 889 ++++++++++-- codex-rs/tui/src/lib.rs | 59 +- codex-rs/tui/src/markdown_render.rs | 79 +- codex-rs/tui/src/markdown_render_tests.rs | 137 +- codex-rs/tui/src/render/highlight.rs | 1233 ++++++++++++++--- codex-rs/tui/src/slash_command.rs | 3 + ...ff_render__tests__diff_gallery_120x40.snap | 44 + ...iff_render__tests__diff_gallery_80x24.snap | 28 + ...iff_render__tests__diff_gallery_94x35.snap | 39 + ...ests__syntax_highlighted_insert_wraps.snap | 14 + ..._syntax_highlighted_insert_wraps_text.snap | 7 + codex-rs/tui/src/theme_picker.rs | 620 +++++++++ 26 files changed, 3726 insertions(+), 317 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__diff_gallery_120x40.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__diff_gallery_80x24.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__diff_gallery_94x35.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__syntax_highlighted_insert_wraps.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__syntax_highlighted_insert_wraps_text.snap create mode 100644 codex-rs/tui/src/theme_picker.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 31bc8d65a7..c910f95442 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -852,6 +852,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -2297,6 +2306,7 @@ dependencies = [ "strum 0.27.2", "strum_macros 0.27.2", "supports-color 3.0.2", + "syntect", "tempfile", "textwrap 0.16.2", "thiserror 2.0.18", @@ -2307,8 +2317,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "tree-sitter-bash", - "tree-sitter-highlight", + "two-face", "unicode-segmentation", "unicode-width 0.2.1", "url", @@ -5157,6 +5166,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-keyutils" version = "0.2.4" @@ -5964,6 +5979,28 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags 2.10.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "opaque-debug" version = "0.3.1" @@ -6381,6 +6418,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "quick-xml", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.0" @@ -8886,6 +8936,27 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax 0.8.8", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.18", + "walkdir", + "yaml-rust", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -9622,18 +9693,6 @@ dependencies = [ "tree-sitter-language", ] -[[package]] -name = "tree-sitter-highlight" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc5f880ad8d8f94e88cb81c3557024cf1a8b75e3b504c50481ed4f5a6006ff3" -dependencies = [ - "regex", - "streaming-iterator", - "thiserror 2.0.18", - "tree-sitter", -] - [[package]] name = "tree-sitter-language" version = "0.1.7" @@ -9701,6 +9760,17 @@ dependencies = [ "utf-8", ] +[[package]] +name = "two-face" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b285c51f8a6ade109ed4566d33ac4fb289fb5d6cf87ed70908a5eaf65e948e34" +dependencies = [ + "serde", + "serde_derive", + "syntect", +] + [[package]] name = "type-map" version = "0.5.1" @@ -10965,6 +11035,15 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d23ed3c27b..e22dba12c2 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -276,7 +276,7 @@ tracing-subscriber = "0.3.22" tracing-test = "0.2.5" tree-sitter = "0.25.10" tree-sitter-bash = "0.25" -tree-sitter-highlight = "0.25.10" +syntect = "5" ts-rs = "11" tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] } uds_windows = "1.1.0" diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index addbcbe313..ed1ae8ddbf 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1400,6 +1400,11 @@ "type": "string" }, "type": "array" + }, + "theme": { + "default": null, + "description": "Syntax highlighting theme name (kebab-case).\n\nWhen set, overrides automatic light/dark theme detection. Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.", + "type": "string" } }, "type": "object" diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 0dfb4efd8e..fceb965998 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -55,6 +55,14 @@ pub enum ConfigEdit { ClearPath { segments: Vec }, } +/// Produces a config edit that sets `[tui] theme = ""`. +pub fn syntax_theme_edit(name: &str) -> ConfigEdit { + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "theme".to_string()], + value: value(name.to_string()), + } +} + pub fn status_line_items_edit(items: &[String]) -> ConfigEdit { let mut array = toml_edit::Array::new(); for item in items { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ddc5aa9cdd..6474ca4bc0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -278,6 +278,9 @@ pub struct Config { /// `current-dir`. pub tui_status_line: Option>, + /// Syntax highlighting theme override (kebab-case name). + pub tui_theme: Option, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -2120,6 +2123,7 @@ impl Config { .map(|t| t.alternate_screen) .unwrap_or_default(), tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()), + tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); @@ -2518,6 +2522,30 @@ allowed_domains = ["openai.com"] Ok(()) } + #[test] + fn tui_theme_deserializes_from_toml() { + let cfg = r#" +[tui] +theme = "dracula" +"#; + let parsed = + toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), + Some("dracula"), + ); + } + + #[test] + fn tui_theme_defaults_to_none() { + let cfg = r#" +[tui] +"#; + let parsed = + toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None); + } + #[test] fn tui_config_missing_notifications_field_defaults_to_enabled() { let cfg = r#" @@ -2537,6 +2565,7 @@ allowed_domains = ["openai.com"] show_tooltips: true, alternate_screen: AltScreenMode::Auto, status_line: None, + theme: None, } ); } @@ -4646,6 +4675,7 @@ model_verbosity = "high" feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_theme: None, otel: OtelConfig::default(), }, o3_profile_config @@ -4768,6 +4798,7 @@ model_verbosity = "high" feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_theme: None, otel: OtelConfig::default(), }; @@ -4888,6 +4919,7 @@ model_verbosity = "high" feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_theme: None, otel: OtelConfig::default(), }; @@ -4994,6 +5026,7 @@ model_verbosity = "high" feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_theme: None, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 2e3573f770..678766e807 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -681,6 +681,13 @@ pub struct Tui { /// `current-dir`. #[serde(default)] pub status_line: Option>, + + /// Syntax highlighting theme name (kebab-case). + /// + /// When set, overrides automatic light/dark theme detection. + /// Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes. + #[serde(default)] + pub theme: Option, } const fn default_true() -> bool { diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index e96e6fbd33..29bf6c5880 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -75,6 +75,9 @@ ignore = [ { id = "RUSTSEC-2024-0436", reason = "paste is unmaintained; pulled in via ratatui/rmcp/starlark used by tui/execpolicy; no fixed release yet" }, # TODO(joshka, nornagon): remove this exception when once we update the ratatui fork to a version that uses lru 0.13+. { id = "RUSTSEC-2026-0002", reason = "lru 0.12.5 is pulled in via ratatui fork; cannot upgrade until the fork is updated" }, + # TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities. + { id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, + { id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 683b0828dc..1b0ed9771e 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -93,8 +93,8 @@ toml = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } -tree-sitter-bash = { workspace = true } -tree-sitter-highlight = { workspace = true } +syntect = "5" +two-face = { version = "0.5", default-features = false, features = ["syntect-default-onig"] } unicode-segmentation = { workspace = true } unicode-width = { workspace = true } url = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ebbf757523..22ceed1ffa 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2605,6 +2605,34 @@ impl App { AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); } + AppEvent::SyntaxThemeSelected { name } => { + let edit = codex_core::config::edit::syntax_theme_edit(&name); + let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await; + match apply_result { + Ok(()) => { + // Ensure the selected theme is active in the current + // session. The preview callback covers arrow-key + // navigation, but if the user presses Enter without + // navigating, the runtime theme must still be applied. + if let Some(theme) = crate::render::highlight::resolve_theme_by_name( + &name, + Some(&self.config.codex_home), + ) { + crate::render::highlight::set_syntax_theme(theme); + } + self.sync_tui_theme_selection(name); + } + Err(err) => { + self.restore_runtime_theme_from_config(); + tracing::error!(error = %err, "failed to persist theme selection"); + self.chat_widget + .add_error_message(format!("Failed to save theme: {err}")); + } + } + } } Ok(AppRunControl::Continue) } @@ -2783,6 +2811,29 @@ impl App { self.chat_widget.set_personality(personality); } + fn sync_tui_theme_selection(&mut self, name: String) { + self.config.tui_theme = Some(name.clone()); + self.chat_widget.set_tui_theme(Some(name)); + } + + fn restore_runtime_theme_from_config(&self) { + if let Some(name) = self.config.tui_theme.as_deref() + && let Some(theme) = + crate::render::highlight::resolve_theme_by_name(name, Some(&self.config.codex_home)) + { + crate::render::highlight::set_syntax_theme(theme); + return; + } + + let auto_theme_name = crate::render::highlight::adaptive_default_theme_name(); + if let Some(theme) = crate::render::highlight::resolve_theme_by_name( + auto_theme_name, + Some(&self.config.codex_home), + ) { + crate::render::highlight::set_syntax_theme(theme); + } + } + fn personality_label(personality: Personality) -> &'static str { match personality { Personality::None => "None", @@ -3655,6 +3706,19 @@ mod tests { Ok(()) } + #[tokio::test] + async fn sync_tui_theme_selection_updates_chat_widget_config_copy() { + let mut app = make_test_app().await; + + app.sync_tui_theme_selection("dracula".to_string()); + + assert_eq!(app.config.tui_theme.as_deref(), Some("dracula")); + assert_eq!( + app.chat_widget.config_ref().tui_theme.as_deref(), + Some("dracula") + ); + } + #[tokio::test] async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 159cda88f4..60c25fcc4e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -360,6 +360,11 @@ pub(crate) enum AppEvent { }, /// Dismiss the status-line setup UI without changing config. StatusLineSetupCancelled, + + /// Apply a user-confirmed syntax theme selection. + SyntaxThemeSelected { + name: String, + }, } /// The exit strategy requested by the UI layer. 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 14f94ca6f4..ad40c1660f 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -33,9 +33,76 @@ use super::selection_popup_common::render_rows_stable_col_widths; use super::selection_popup_common::render_rows_with_col_width_mode; use unicode_width::UnicodeWidthStr; +/// Minimum list width (in content columns) required before the side-by-side +/// layout is activated. Keeps the list usable even when sharing horizontal +/// space with the side content panel. +const MIN_LIST_WIDTH_FOR_SIDE: u16 = 40; + +/// Horizontal gap (in columns) between the list area and the side content +/// panel when side-by-side layout is active. +const SIDE_CONTENT_GAP: u16 = 2; + +/// Shared menu-surface horizontal inset (2 cells per side) used by selection popups. +const MENU_SURFACE_HORIZONTAL_INSET: u16 = 4; + +/// Controls how the side content panel is sized relative to the popup width. +/// +/// When the computed side width falls below `side_content_min_width` or the +/// remaining list area would be narrower than [`MIN_LIST_WIDTH_FOR_SIDE`], the +/// side-by-side layout is abandoned and the stacked fallback is used instead. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SideContentWidth { + /// Fixed number of columns. `Fixed(0)` disables side content entirely. + Fixed(u16), + /// Exact 50/50 split of the content area (minus the inter-column gap). + Half, +} + +impl Default for SideContentWidth { + fn default() -> Self { + Self::Fixed(0) + } +} + +/// Returns the popup content width after subtracting the shared menu-surface +/// horizontal inset (2 columns on each side). +pub(crate) fn popup_content_width(total_width: u16) -> u16 { + total_width.saturating_sub(MENU_SURFACE_HORIZONTAL_INSET) +} + +/// Returns side-by-side layout widths as `(list_width, side_width)` when the +/// layout can fit. Returns `None` when the side panel is disabled/too narrow or +/// when the remaining list width would become unusably small. +pub(crate) fn side_by_side_layout_widths( + content_width: u16, + side_content_width: SideContentWidth, + side_content_min_width: u16, +) -> Option<(u16, u16)> { + let side_width = match side_content_width { + SideContentWidth::Fixed(0) => return None, + SideContentWidth::Fixed(width) => width, + SideContentWidth::Half => content_width.saturating_sub(SIDE_CONTENT_GAP) / 2, + }; + if side_width < side_content_min_width { + return None; + } + let list_width = content_width.saturating_sub(SIDE_CONTENT_GAP + side_width); + (list_width >= MIN_LIST_WIDTH_FOR_SIDE).then_some((list_width, side_width)) +} + /// One selectable item in the generic selection list. pub(crate) type SelectionAction = Box; +/// Callback invoked whenever the highlighted item changes (arrow keys, search +/// filter, number-key jump). Receives the *actual* index into the unfiltered +/// `items` list and the event sender. Used by the theme picker for live preview. +pub(crate) type OnSelectionChangedCallback = + Option>; + +/// Callback invoked when the picker is dismissed without accepting (Esc or +/// Ctrl+C). Used by the theme picker to restore the pre-open theme. +pub(crate) type OnCancelCallback = Option>; + /// One row in a [`ListSelectionView`] selection list. /// /// This is the source-of-truth model for row state before filtering and @@ -79,6 +146,28 @@ pub(crate) struct SelectionViewParams { pub col_width_mode: ColumnWidthMode, pub header: Box, pub initial_selected_idx: Option, + + /// Rich content rendered beside (wide terminals) or below (narrow terminals) + /// the list items, inside the bordered menu surface. Used by the theme picker + /// to show a syntax-highlighted preview. + pub side_content: Box, + + /// Width mode for side content when side-by-side layout is active. + pub side_content_width: SideContentWidth, + + /// Minimum side panel width required before side-by-side layout activates. + pub side_content_min_width: u16, + + /// Optional fallback content rendered when side-by-side does not fit. + /// When absent, `side_content` is reused. + pub stacked_side_content: Option>, + + /// Called when the highlighted item changes (navigation, filter, number-key). + /// Receives the *actual* item index, not the filtered/visible index. + pub on_selection_changed: OnSelectionChangedCallback, + + /// Called when the picker is dismissed via Esc/Ctrl+C without selecting. + pub on_cancel: OnCancelCallback, } impl Default for SelectionViewParams { @@ -95,6 +184,12 @@ impl Default for SelectionViewParams { col_width_mode: ColumnWidthMode::AutoVisible, header: Box::new(()), initial_selected_idx: None, + side_content: Box::new(()), + side_content_width: SideContentWidth::default(), + side_content_min_width: 0, + stacked_side_content: None, + on_selection_changed: None, + on_cancel: None, } } } @@ -120,6 +215,16 @@ pub(crate) struct ListSelectionView { last_selected_actual_idx: Option, header: Box, initial_selected_idx: Option, + side_content: Box, + side_content_width: SideContentWidth, + side_content_min_width: u16, + stacked_side_content: Option>, + + /// Called when the highlighted item changes (navigation, filter, number-key). + on_selection_changed: OnSelectionChangedCallback, + + /// Called when the picker is dismissed via Esc/Ctrl+C without selecting. + on_cancel: OnCancelCallback, } impl ListSelectionView { @@ -161,6 +266,12 @@ impl ListSelectionView { last_selected_actual_idx: None, header, initial_selected_idx: params.initial_selected_idx, + side_content: params.side_content, + side_content_width: params.side_content_width, + side_content_min_width: params.side_content_min_width, + stacked_side_content: params.stacked_side_content, + on_selection_changed: params.on_selection_changed, + on_cancel: params.on_cancel, }; s.apply_filter(); s @@ -174,11 +285,15 @@ impl ListSelectionView { MAX_POPUP_ROWS.min(len.max(1)) } - fn apply_filter(&mut self) { - let previously_selected = self - .state + fn selected_actual_idx(&self) -> Option { + self.state .selected_idx .and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied()) + } + + fn apply_filter(&mut self) { + let previously_selected = self + .selected_actual_idx() .or_else(|| { (!self.is_searchable) .then(|| self.items.iter().position(|item| item.is_current)) @@ -222,6 +337,13 @@ impl ListSelectionView { let visible = Self::max_visible_rows(len); self.state.clamp_selection(len); self.state.ensure_visible(len, visible); + + // Notify the callback when filtering changes the selected actual item + // so live preview stays in sync (e.g. typing in the theme picker). + let new_actual = self.selected_actual_idx(); + if new_actual != previously_selected { + self.fire_selection_changed(); + } } fn build_rows(&self) -> Vec { @@ -273,19 +395,35 @@ impl ListSelectionView { } fn move_up(&mut self) { + let before = self.selected_actual_idx(); 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(); + if self.selected_actual_idx() != before { + self.fire_selection_changed(); + } } fn move_down(&mut self) { + let before = self.selected_actual_idx(); 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(); + if self.selected_actual_idx() != before { + self.fire_selection_changed(); + } + } + + fn fire_selection_changed(&self) { + if let Some(cb) = &self.on_selection_changed + && let Some(actual) = self.selected_actual_idx() + { + cb(actual, &self.app_event_tx); + } } fn accept(&mut self) { @@ -310,6 +448,9 @@ impl ListSelectionView { self.complete = true; } } else if selected_item.is_none() { + if let Some(cb) = &self.on_cancel { + cb(&self.app_event_tx); + } self.complete = true; } } @@ -328,6 +469,63 @@ impl ListSelectionView { total_width.saturating_sub(2) } + fn clear_to_terminal_bg(buf: &mut Buffer, area: Rect) { + let buf_area = buf.area(); + let min_x = area.x.max(buf_area.x); + let min_y = area.y.max(buf_area.y); + let max_x = area + .x + .saturating_add(area.width) + .min(buf_area.x.saturating_add(buf_area.width)); + let max_y = area + .y + .saturating_add(area.height) + .min(buf_area.y.saturating_add(buf_area.height)); + for y in min_y..max_y { + for x in min_x..max_x { + buf[(x, y)] + .set_symbol(" ") + .set_style(ratatui::style::Style::reset()); + } + } + } + + fn force_bg_to_terminal_bg(buf: &mut Buffer, area: Rect) { + let buf_area = buf.area(); + let min_x = area.x.max(buf_area.x); + let min_y = area.y.max(buf_area.y); + let max_x = area + .x + .saturating_add(area.width) + .min(buf_area.x.saturating_add(buf_area.width)); + let max_y = area + .y + .saturating_add(area.height) + .min(buf_area.y.saturating_add(buf_area.height)); + for y in min_y..max_y { + for x in min_x..max_x { + buf[(x, y)].set_bg(ratatui::style::Color::Reset); + } + } + } + + fn stacked_side_content(&self) -> &dyn Renderable { + self.stacked_side_content + .as_deref() + .unwrap_or_else(|| self.side_content.as_ref()) + } + + /// Returns `Some(side_width)` when the content area is wide enough for a + /// side-by-side layout (list + gap + side panel), `None` otherwise. + fn side_layout_width(&self, content_width: u16) -> Option { + side_by_side_layout_widths( + content_width, + self.side_content_width, + self.side_content_min_width, + ) + .map(|(_, side_width)| side_width) + } + fn skip_disabled_down(&mut self) { let len = self.visible_len(); for _ in 0..len { @@ -469,6 +667,9 @@ impl BottomPaneView for ListSelectionView { } fn on_ctrl_c(&mut self) -> CancellationEvent { + if let Some(cb) = &self.on_cancel { + cb(&self.app_event_tx); + } self.complete = true; CancellationEvent::Handled } @@ -476,38 +677,59 @@ impl BottomPaneView for ListSelectionView { impl Renderable for ListSelectionView { fn desired_height(&self, width: u16) -> u16 { - // Measure wrapped height for up to MAX_POPUP_ROWS items at the given width. - // Build the same display rows used by the renderer so wrapping math matches. + // Inner content width after menu surface horizontal insets (2 per side). + let inner_width = popup_content_width(width); + + // When side-by-side is active, measure the list at the reduced width + // that accounts for the gap and side panel. + let effective_rows_width = if let Some(side_w) = self.side_layout_width(inner_width) { + Self::rows_width(width).saturating_sub(SIDE_CONTENT_GAP + side_w) + } else { + Self::rows_width(width) + }; + + // Measure wrapped height for up to MAX_POPUP_ROWS items. let rows = self.build_rows(); - let rows_width = Self::rows_width(width); let rows_height = match self.col_width_mode { ColumnWidthMode::AutoVisible => measure_rows_height( &rows, &self.state, MAX_POPUP_ROWS, - rows_width.saturating_add(1), + effective_rows_width.saturating_add(1), ), ColumnWidthMode::AutoAllRows => measure_rows_height_stable_col_widths( &rows, &self.state, MAX_POPUP_ROWS, - rows_width.saturating_add(1), + effective_rows_width.saturating_add(1), ), ColumnWidthMode::Fixed => measure_rows_height_with_col_width_mode( &rows, &self.state, MAX_POPUP_ROWS, - rows_width.saturating_add(1), + effective_rows_width.saturating_add(1), ColumnWidthMode::Fixed, ), }; - // Subtract 4 for the padding on the left and right of the header. - let mut height = self.header.desired_height(width.saturating_sub(4)); + let mut height = self.header.desired_height(inner_width); height = height.saturating_add(rows_height + 3); if self.is_searchable { height = height.saturating_add(1); } + + // Side content: when the terminal is wide enough the panel sits beside + // the list and shares vertical space; otherwise it stacks below. + if self.side_layout_width(inner_width).is_some() { + // Side-by-side — side content shares list rows vertically so it + // doesn't add to total height. + } else { + let side_h = self.stacked_side_content().desired_height(inner_width); + if side_h > 0 { + height = height.saturating_add(1 + side_h); + } + } + if let Some(note) = &self.footer_note { let note_width = width.saturating_sub(2); let note_lines = wrap_styled_line(note, note_width); @@ -538,41 +760,60 @@ impl Renderable for ListSelectionView { // Paint the shared menu surface and then layout inside the returned inset. let content_area = render_menu_surface(outer_content_area, buf); - let header_height = self - .header - // Subtract 4 for the padding on the left and right of the header. - .desired_height(outer_content_area.width.saturating_sub(4)); + let inner_width = popup_content_width(outer_content_area.width); + let side_w = self.side_layout_width(inner_width); + + // When side-by-side is active, shrink the list to make room. + let full_rows_width = Self::rows_width(outer_content_area.width); + let effective_rows_width = if let Some(sw) = side_w { + full_rows_width.saturating_sub(SIDE_CONTENT_GAP + sw) + } else { + full_rows_width + }; + + let header_height = self.header.desired_height(inner_width); let rows = self.build_rows(); - let rows_width = Self::rows_width(outer_content_area.width); let rows_height = match self.col_width_mode { ColumnWidthMode::AutoVisible => measure_rows_height( &rows, &self.state, MAX_POPUP_ROWS, - rows_width.saturating_add(1), + effective_rows_width.saturating_add(1), ), ColumnWidthMode::AutoAllRows => measure_rows_height_stable_col_widths( &rows, &self.state, MAX_POPUP_ROWS, - rows_width.saturating_add(1), + effective_rows_width.saturating_add(1), ), ColumnWidthMode::Fixed => measure_rows_height_with_col_width_mode( &rows, &self.state, MAX_POPUP_ROWS, - rows_width.saturating_add(1), + effective_rows_width.saturating_add(1), ColumnWidthMode::Fixed, ), }; - let [header_area, _, search_area, list_area] = Layout::vertical([ + + // Stacked (fallback) side content height — only used when not side-by-side. + let stacked_side_h = if side_w.is_none() { + self.stacked_side_content().desired_height(inner_width) + } else { + 0 + }; + let stacked_gap = if stacked_side_h > 0 { 1 } else { 0 }; + + let [header_area, _, search_area, list_area, _, stacked_side_area] = Layout::vertical([ Constraint::Max(header_height), Constraint::Max(1), Constraint::Length(if self.is_searchable { 1 } else { 0 }), Constraint::Length(rows_height), + Constraint::Length(stacked_gap), + Constraint::Length(stacked_side_h), ]) .areas(content_area); + // -- Header -- if header_area.height < header_height { let [header_area, elision_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area); @@ -585,6 +826,7 @@ impl Renderable for ListSelectionView { self.header.render(header_area, buf); } + // -- Search bar -- if self.is_searchable { Line::from(self.search_query.clone()).render(search_area, buf); let query_span: Span<'static> = if self.search_query.is_empty() { @@ -598,11 +840,12 @@ impl Renderable for ListSelectionView { Line::from(query_span).render(search_area, buf); } + // -- List rows -- if list_area.height > 0 { let render_area = Rect { x: list_area.x.saturating_sub(2), y: list_area.y, - width: rows_width.max(1), + width: effective_rows_width.max(1), height: list_area.height, }; match self.col_width_mode { @@ -634,6 +877,53 @@ impl Renderable for ListSelectionView { }; } + // -- Side content (preview panel) -- + if let Some(sw) = side_w { + // Side-by-side: render to the right half of the popup content + // area so preview content can center vertically in that panel. + let side_x = content_area.x + content_area.width - sw; + let side_area = Rect::new(side_x, content_area.y, sw, content_area.height); + + // Clear the menu-surface background behind the side panel so the + // preview appears on the terminal's own background. + let clear_x = side_x.saturating_sub(SIDE_CONTENT_GAP); + let clear_w = outer_content_area + .x + .saturating_add(outer_content_area.width) + .saturating_sub(clear_x); + Self::clear_to_terminal_bg( + buf, + Rect::new( + clear_x, + outer_content_area.y, + clear_w, + outer_content_area.height, + ), + ); + self.side_content.render(side_area, buf); + Self::force_bg_to_terminal_bg( + buf, + Rect::new( + clear_x, + outer_content_area.y, + clear_w, + outer_content_area.height, + ), + ); + } else if stacked_side_area.height > 0 { + // Stacked fallback: render below the list (same as old footer_content). + let clear_height = (outer_content_area.y + outer_content_area.height) + .saturating_sub(stacked_side_area.y); + let clear_area = Rect::new( + outer_content_area.x, + stacked_side_area.y, + outer_content_area.width, + clear_height, + ); + Self::clear_to_terminal_bg(buf, clear_area); + self.stacked_side_content().render(stacked_side_area, buf); + } + if footer_area.height > 0 { let [note_area, hint_area] = Layout::vertical([ Constraint::Length(note_height), @@ -683,9 +973,33 @@ mod tests { use crossterm::event::KeyCode; use insta::assert_snapshot; use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; use ratatui::layout::Rect; + use ratatui::style::Color; + use ratatui::style::Style; use tokio::sync::mpsc::unbounded_channel; + struct MarkerRenderable { + marker: &'static str, + height: u16, + } + + impl Renderable for MarkerRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + for y in area.y..area.y.saturating_add(area.height) { + for x in area.x..area.x.saturating_add(area.width) { + if x < buf.area().width && y < buf.area().height { + buf[(x, y)].set_symbol(self.marker); + } + } + } + } + + fn desired_height(&self, _width: u16) -> u16 { + self.height + } + } + fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); @@ -722,7 +1036,10 @@ mod tests { } fn render_lines_with_width(view: &ListSelectionView, width: u16) -> String { - let height = view.desired_height(width); + render_lines_in_area(view, width, view.desired_height(width)) + } + + fn render_lines_in_area(view: &ListSelectionView, width: u16, height: u16) -> String { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); view.render(area, &mut buf); @@ -808,6 +1125,20 @@ mod tests { assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); } + #[test] + fn theme_picker_subtitle_uses_fallback_text_in_94x35_terminal() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let home = dirs::home_dir().expect("home directory should be available"); + let codex_home = home.join(".codex"); + let params = + crate::theme_picker::build_theme_picker_params(None, Some(&codex_home), Some(94)); + let view = ListSelectionView::new(params, tx); + + let rendered = render_lines_in_area(&view, 94, 35); + assert!(rendered.contains("Move up/down to live preview themes")); + } + #[test] fn snapshot_footer_note_wraps() { let (tx_raw, _rx) = unbounded_channel::(); @@ -871,6 +1202,66 @@ mod tests { ); } + #[test] + fn enter_with_no_matches_triggers_cancel_callback() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ListSelectionView::new( + SelectionViewParams { + items: vec![SelectionItem { + name: "Read Only".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + is_searchable: true, + on_cancel: Some(Box::new(|tx: &_| { + tx.send(AppEvent::OpenApprovalsPopup); + })), + ..Default::default() + }, + tx, + ); + view.set_search_query("no-matches".to_string()); + + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert!(view.is_complete()); + match rx.try_recv() { + Ok(AppEvent::OpenApprovalsPopup) => {} + Ok(other) => panic!("expected OpenApprovalsPopup cancel event, got {other:?}"), + Err(err) => panic!("expected cancel callback event, got {err}"), + } + } + + #[test] + fn move_down_without_selection_change_does_not_fire_callback() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ListSelectionView::new( + SelectionViewParams { + items: vec![SelectionItem { + name: "Only choice".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + on_selection_changed: Some(Box::new(|_idx, tx: &_| { + tx.send(AppEvent::OpenApprovalsPopup); + })), + ..Default::default() + }, + tx, + ); + + while rx.try_recv().is_ok() {} + + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + + assert!( + rx.try_recv().is_err(), + "moving down in a single-item list should not fire on_selection_changed", + ); + } + #[test] fn wraps_long_option_without_overflowing_columns() { let (tx_raw, _rx) = unbounded_channel::(); @@ -1159,4 +1550,194 @@ mod tests { "fixed description column changed across scroll:\nbefore:\n{before_scroll}\nafter:\n{after_scroll}" ); } + + #[test] + fn side_layout_width_half_uses_exact_split() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + SelectionViewParams { + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(MarkerRenderable { + marker: "W", + height: 1, + }), + side_content_width: SideContentWidth::Half, + side_content_min_width: 10, + ..Default::default() + }, + tx, + ); + + let content_width: u16 = 120; + let expected = content_width.saturating_sub(SIDE_CONTENT_GAP) / 2; + assert_eq!(view.side_layout_width(content_width), Some(expected)); + } + + #[test] + fn side_layout_width_half_falls_back_when_list_would_be_too_narrow() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + SelectionViewParams { + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(MarkerRenderable { + marker: "W", + height: 1, + }), + side_content_width: SideContentWidth::Half, + side_content_min_width: 50, + ..Default::default() + }, + tx, + ); + + assert_eq!(view.side_layout_width(80), None); + } + + #[test] + fn stacked_side_content_is_used_when_side_by_side_does_not_fit() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(MarkerRenderable { + marker: "W", + height: 1, + }), + stacked_side_content: Some(Box::new(MarkerRenderable { + marker: "N", + height: 1, + })), + side_content_width: SideContentWidth::Half, + side_content_min_width: 60, + ..Default::default() + }, + tx, + ); + + let rendered = render_lines_with_width(&view, 70); + assert!( + rendered.contains('N'), + "expected stacked marker to be rendered:\n{rendered}" + ); + assert!( + !rendered.contains('W'), + "wide marker should not render in stacked mode:\n{rendered}" + ); + } + + #[test] + fn side_content_clearing_resets_symbols_and_style() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(MarkerRenderable { + marker: "W", + height: 1, + }), + side_content_width: SideContentWidth::Half, + side_content_min_width: 10, + ..Default::default() + }, + tx, + ); + + let width = 120; + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + for y in 0..height { + for x in 0..width { + buf[(x, y)] + .set_symbol("X") + .set_style(Style::default().bg(Color::Red)); + } + } + view.render(area, &mut buf); + + let cell = &buf[(width - 1, 0)]; + assert_eq!(cell.symbol(), " "); + let style = cell.style(); + assert_eq!(style.fg, Some(Color::Reset)); + assert_eq!(style.bg, Some(Color::Reset)); + assert_eq!(style.underline_color, Some(Color::Reset)); + + let mut saw_marker = false; + for y in 0..height { + for x in 0..width { + let cell = &buf[(x, y)]; + if cell.symbol() == "W" { + saw_marker = true; + assert_eq!(cell.style().bg, Some(Color::Reset)); + } + } + } + assert!( + saw_marker, + "expected side marker renderable to draw into buffer" + ); + } + + #[test] + fn side_content_clearing_handles_non_zero_buffer_origin() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(MarkerRenderable { + marker: "W", + height: 1, + }), + side_content_width: SideContentWidth::Half, + side_content_min_width: 10, + ..Default::default() + }, + tx, + ); + + let width = 120; + let height = view.desired_height(width); + let area = Rect::new(0, 20, width, height); + let mut buf = Buffer::empty(area); + for y in area.y..area.y + height { + for x in area.x..area.x + width { + buf[(x, y)] + .set_symbol("X") + .set_style(Style::default().bg(Color::Red)); + } + } + view.render(area, &mut buf); + + let cell = &buf[(area.x + width - 1, area.y)]; + assert_eq!(cell.symbol(), " "); + assert_eq!(cell.style().bg, Some(Color::Reset)); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 234bb8b728..9c7b940f0e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -78,6 +78,9 @@ mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; pub(crate) use list_selection_view::ColumnWidthMode; pub(crate) use list_selection_view::SelectionViewParams; +pub(crate) use list_selection_view::SideContentWidth; +pub(crate) use list_selection_view::popup_content_width; +pub(crate) use list_selection_view::side_by_side_layout_widths; mod feedback_view; pub(crate) use feedback_view::FeedbackAudience; pub(crate) use feedback_view::feedback_disabled_params; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9bfddd18e4..ba7013c3c1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3482,6 +3482,9 @@ impl ChatWidget { SlashCommand::Statusline => { self.open_status_line_setup(); } + SlashCommand::Theme => { + self.open_theme_picker(); + } SlashCommand::Ps => { self.add_ps_output(); } @@ -4431,6 +4434,20 @@ impl ChatWidget { self.bottom_pane.show_view(Box::new(view)); } + fn open_theme_picker(&mut self) { + let codex_home = codex_core::config::find_codex_home().ok(); + let terminal_width = self + .last_rendered_width + .get() + .and_then(|width| u16::try_from(width).ok()); + let params = crate::theme_picker::build_theme_picker_params( + self.config.tui_theme.as_deref(), + codex_home.as_deref(), + terminal_width, + ); + self.bottom_pane.show_selection_view(params); + } + /// Parses configured status-line ids into known items and collects unknown ids. /// /// Unknown ids are deduplicated in insertion order for warning messages. @@ -6338,6 +6355,11 @@ impl ChatWidget { self.config.personality = Some(personality); } + /// Set the syntax theme override in the widget's config copy. + pub(crate) fn set_tui_theme(&mut self, theme: Option) { + self.config.tui_theme = theme; + } + /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { self.current_collaboration_mode = diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap index 1c6a3ef136..055a6292f1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -27,6 +27,9 @@ Buffer { x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 7, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE, + x: 8, y: 7, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE, + x: 20, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 6fdf3a43f5..aa0481d044 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -1,3 +1,23 @@ +//! Renders unified diffs with line numbers, gutter signs, and optional syntax +//! highlighting. +//! +//! Each `FileChange` variant (Add / Delete / Update) is rendered as a block of +//! diff lines, each prefixed by a right-aligned line number, a gutter sign +//! (`+` / `-` / ` `), and the content text. When a recognized file extension +//! is present, the content text is syntax-highlighted using +//! [`crate::render::highlight`]. +//! +//! **Highlighting strategy for `Update` diffs:** the renderer highlights each +//! hunk as a single concatenated block rather than line-by-line. This +//! preserves syntect's parser state across consecutive lines within a hunk +//! (important for multi-line strings, block comments, etc.). Cross-hunk state +//! is intentionally *not* preserved because hunks are visually separated and +//! re-synchronize at context boundaries anyway. +//! +//! **Wrapping:** long lines are hard-wrapped at the available column width. +//! Syntax-highlighted spans are split at character boundaries with styles +//! preserved across the split so that no color information is lost. + use diffy::Hunk; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -12,8 +32,15 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; +use unicode_width::UnicodeWidthChar; + +/// Display width of a tab character in columns. +const TAB_WIDTH: usize = 4; + use crate::exec_command::relativize_to_home; use crate::render::Insets; +use crate::render::highlight::exceeds_highlight_limits; +use crate::render::highlight::highlight_code_to_styled_spans; use crate::render::line_utils::prefix_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::InsetRenderable; @@ -21,8 +48,13 @@ use crate::render::renderable::Renderable; use codex_core::git_info::get_git_repo_root; use codex_protocol::protocol::FileChange; -// Internal representation for diff line rendering -enum DiffLineType { +/// Classifies a diff line for gutter sign rendering and style selection. +/// +/// `Insert` renders with a `+` sign and green text, `Delete` with `-` and red +/// text (plus dim overlay when syntax-highlighted), and `Context` with a space +/// and default styling. +#[derive(Clone, Copy)] +pub(crate) enum DiffLineType { Insert, Delete, Context, @@ -42,13 +74,13 @@ impl DiffSummary { impl Renderable for FileChange { fn render(&self, area: Rect, buf: &mut Buffer) { let mut lines = vec![]; - render_change(self, &mut lines, area.width as usize); + render_change(self, &mut lines, area.width as usize, None); Paragraph::new(lines).render(area, buf); } fn desired_height(&self, width: u16) -> u16 { let mut lines = vec![]; - render_change(self, &mut lines, width as usize); + render_change(self, &mut lines, width as usize, None); lines.len() as u16 } } @@ -185,47 +217,100 @@ fn render_changes_block(rows: Vec, wrap_cols: usize, cwd: &Path) -> Vec>, width: usize) { +/// Detect the programming language for a file path by its extension. +/// Returns the raw extension string for `normalize_lang` / `find_syntax` +/// to resolve downstream. +fn detect_lang_for_path(path: &Path) -> Option { + let ext = path.extension()?.to_str()?; + Some(ext.to_string()) +} + +fn render_change( + change: &FileChange, + out: &mut Vec>, + width: usize, + lang: Option<&str>, +) { match change { FileChange::Add { content } => { + // Pre-highlight the entire file content as a whole. + let syntax_lines = lang.and_then(|l| highlight_code_to_styled_spans(content, l)); let line_number_width = line_number_width(content.lines().count()); for (i, raw) in content.lines().enumerate() { - out.extend(push_wrapped_diff_line( - i + 1, - DiffLineType::Insert, - raw, - width, - line_number_width, - )); + let syn = syntax_lines.as_ref().and_then(|sl| sl.get(i)); + if let Some(spans) = syn { + out.extend(push_wrapped_diff_line_with_syntax( + i + 1, + DiffLineType::Insert, + raw, + width, + line_number_width, + spans, + )); + } else { + out.extend(push_wrapped_diff_line( + i + 1, + DiffLineType::Insert, + raw, + width, + line_number_width, + )); + } } } FileChange::Delete { content } => { + let syntax_lines = lang.and_then(|l| highlight_code_to_styled_spans(content, l)); let line_number_width = line_number_width(content.lines().count()); for (i, raw) in content.lines().enumerate() { - out.extend(push_wrapped_diff_line( - i + 1, - DiffLineType::Delete, - raw, - width, - line_number_width, - )); + let syn = syntax_lines.as_ref().and_then(|sl| sl.get(i)); + if let Some(spans) = syn { + out.extend(push_wrapped_diff_line_with_syntax( + i + 1, + DiffLineType::Delete, + raw, + width, + line_number_width, + spans, + )); + } else { + out.extend(push_wrapped_diff_line( + i + 1, + DiffLineType::Delete, + raw, + width, + line_number_width, + )); + } } } FileChange::Update { unified_diff, .. } => { if let Ok(patch) = diffy::Patch::from_str(unified_diff) { let mut max_line_number = 0; + let mut total_diff_bytes: usize = 0; + let mut total_diff_lines: usize = 0; for h in patch.hunks() { let mut old_ln = h.old_range().start(); let mut new_ln = h.new_range().start(); for l in h.lines() { + let text = match l { + diffy::Line::Insert(t) + | diffy::Line::Delete(t) + | diffy::Line::Context(t) => t, + }; + total_diff_bytes += text.len(); + total_diff_lines += 1; match l { diffy::Line::Insert(_) => { max_line_number = max_line_number.max(new_ln); @@ -243,6 +328,16 @@ fn render_change(change: &FileChange, out: &mut Vec>, width: usi } } } + + // Skip per-line syntax highlighting when the patch is too + // large — avoids thousands of parser initializations that + // would stall rendering on big diffs. + let diff_lang = if exceeds_highlight_limits(total_diff_bytes, total_diff_lines) { + None + } else { + lang + }; + let line_number_width = line_number_width(max_line_number); let mut is_first_hunk = true; for h in patch.hunks() { @@ -253,41 +348,93 @@ fn render_change(change: &FileChange, out: &mut Vec>, width: usi } is_first_hunk = false; + // Highlight each hunk as a single block so syntect parser + // state is preserved across consecutive lines. + let hunk_syntax_lines = diff_lang.and_then(|language| { + let hunk_text: String = h + .lines() + .iter() + .map(|line| match line { + diffy::Line::Insert(text) + | diffy::Line::Delete(text) + | diffy::Line::Context(text) => *text, + }) + .collect(); + let syntax_lines = highlight_code_to_styled_spans(&hunk_text, language)?; + (syntax_lines.len() == h.lines().len()).then_some(syntax_lines) + }); + let mut old_ln = h.old_range().start(); let mut new_ln = h.new_range().start(); - for l in h.lines() { + for (line_idx, l) in h.lines().iter().enumerate() { + let syntax_spans = hunk_syntax_lines + .as_ref() + .and_then(|syntax_lines| syntax_lines.get(line_idx)); match l { diffy::Line::Insert(text) => { let s = text.trim_end_matches('\n'); - out.extend(push_wrapped_diff_line( - new_ln, - DiffLineType::Insert, - s, - width, - line_number_width, - )); + if let Some(syn) = syntax_spans { + out.extend(push_wrapped_diff_line_with_syntax( + new_ln, + DiffLineType::Insert, + s, + width, + line_number_width, + syn, + )); + } else { + out.extend(push_wrapped_diff_line( + new_ln, + DiffLineType::Insert, + s, + width, + line_number_width, + )); + } new_ln += 1; } diffy::Line::Delete(text) => { let s = text.trim_end_matches('\n'); - out.extend(push_wrapped_diff_line( - old_ln, - DiffLineType::Delete, - s, - width, - line_number_width, - )); + if let Some(syn) = syntax_spans { + out.extend(push_wrapped_diff_line_with_syntax( + old_ln, + DiffLineType::Delete, + s, + width, + line_number_width, + syn, + )); + } else { + out.extend(push_wrapped_diff_line( + old_ln, + DiffLineType::Delete, + s, + width, + line_number_width, + )); + } old_ln += 1; } diffy::Line::Context(text) => { let s = text.trim_end_matches('\n'); - out.extend(push_wrapped_diff_line( - new_ln, - DiffLineType::Context, - s, - width, - line_number_width, - )); + if let Some(syn) = syntax_spans { + out.extend(push_wrapped_diff_line_with_syntax( + new_ln, + DiffLineType::Context, + s, + width, + line_number_width, + syn, + )); + } else { + out.extend(push_wrapped_diff_line( + new_ln, + DiffLineType::Context, + s, + width, + line_number_width, + )); + } old_ln += 1; new_ln += 1; } @@ -342,68 +489,213 @@ pub(crate) fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) { } } -fn push_wrapped_diff_line( +/// Render a single diff line (no syntax highlighting) as one or more wrapped +/// ratatui `Line`s. The first output line carries the gutter sign; continuation +/// lines are indented under the line number column. +pub(crate) fn push_wrapped_diff_line( line_number: usize, kind: DiffLineType, text: &str, width: usize, line_number_width: usize, +) -> Vec> { + push_wrapped_diff_line_inner(line_number, kind, text, width, line_number_width, None) +} + +/// Render a single diff line with pre-computed syntax spans. The sign character +/// uses the diff color; content gets syntax colors with a dim overlay for delete +/// lines. +pub(crate) fn push_wrapped_diff_line_with_syntax( + line_number: usize, + kind: DiffLineType, + text: &str, + width: usize, + line_number_width: usize, + syntax_spans: &[RtSpan<'static>], +) -> Vec> { + push_wrapped_diff_line_inner( + line_number, + kind, + text, + width, + line_number_width, + Some(syntax_spans), + ) +} + +fn push_wrapped_diff_line_inner( + line_number: usize, + kind: DiffLineType, + text: &str, + width: usize, + line_number_width: usize, + syntax_spans: Option<&[RtSpan<'static>]>, ) -> Vec> { let ln_str = line_number.to_string(); - let mut remaining_text: &str = text; // Reserve a fixed number of spaces (equal to the widest line number plus a // trailing spacer) so the sign column stays aligned across the diff block. let gutter_width = line_number_width.max(1); let prefix_cols = gutter_width + 1; - let mut first = true; let (sign_char, line_style) = match kind { DiffLineType::Insert => ('+', style_add()), DiffLineType::Delete => ('-', style_del()), DiffLineType::Context => (' ', style_context()), }; - let mut lines: Vec> = Vec::new(); - loop { - // Fit the content for the current terminal row: - // compute how many columns are available after the prefix, then split - // at a UTF-8 character boundary so this row's chunk fits exactly. + // When we have syntax spans, compose them with the diff style for a richer + // view. The sign character keeps the diff color; content gets syntax colors + // with an overlay modifier for delete lines (dim). + if let Some(syn_spans) = syntax_spans { + let gutter = format!("{ln_str:>gutter_width$} "); + let sign = format!("{sign_char}"); + let dim_overlay = matches!(kind, DiffLineType::Delete); + + // Apply dim overlay to syntax spans if this is a delete line. + let styled: Vec> = syn_spans + .iter() + .map(|sp| { + let mut style = sp.style; + if dim_overlay { + style.add_modifier |= Modifier::DIM; + } + RtSpan::styled(sp.content.clone().into_owned(), style) + }) + .collect(); + + // Determine how many display columns remain for content after the + // gutter and sign character. let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1); - let split_at_byte_index = remaining_text - .char_indices() - .nth(available_content_cols) - .map(|(i, _)| i) - .unwrap_or_else(|| remaining_text.len()); - let (chunk, rest) = remaining_text.split_at(split_at_byte_index); - remaining_text = rest; - if first { - // Build gutter (right-aligned line number plus spacer) as a dimmed span - let gutter = format!("{ln_str:>gutter_width$} "); - // Content with a sign ('+'/'-'/' ') styled per diff kind - let content = format!("{sign_char}{chunk}"); - lines.push(RtLine::from(vec![ - RtSpan::styled(gutter, style_gutter()), - RtSpan::styled(content, line_style), - ])); - first = false; - } else { - // Continuation lines keep a space for the sign column so content aligns - let gutter = format!("{:gutter_width$} ", ""); - lines.push(RtLine::from(vec![ - RtSpan::styled(gutter, style_gutter()), - RtSpan::styled(chunk.to_string(), line_style), - ])); - } - if remaining_text.is_empty() { - break; + // Wrap the styled content spans to fit within the available columns. + let wrapped_chunks = wrap_styled_spans(&styled, available_content_cols); + + let mut lines: Vec> = Vec::new(); + for (i, chunk) in wrapped_chunks.into_iter().enumerate() { + let mut row_spans: Vec> = Vec::new(); + if i == 0 { + // First line: gutter + sign + content + row_spans.push(RtSpan::styled(gutter.clone(), style_gutter())); + row_spans.push(RtSpan::styled(sign.clone(), line_style)); + } else { + // Continuation: empty gutter + two-space indent (matches + // the plain-text wrapping continuation style). + let cont_gutter = format!("{:gutter_width$} ", ""); + row_spans.push(RtSpan::styled(cont_gutter, style_gutter())); + } + row_spans.extend(chunk); + lines.push(RtLine::from(row_spans)); } + return lines; } + + let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1); + let styled = vec![RtSpan::styled(text.to_string(), line_style)]; + let wrapped_chunks = wrap_styled_spans(&styled, available_content_cols); + + let mut lines: Vec> = Vec::new(); + for (i, chunk) in wrapped_chunks.into_iter().enumerate() { + let mut row_spans: Vec> = Vec::new(); + if i == 0 { + let gutter = format!("{ln_str:>gutter_width$} "); + let sign = format!("{sign_char}"); + row_spans.push(RtSpan::styled(gutter, style_gutter())); + row_spans.push(RtSpan::styled(sign, line_style)); + } else { + let cont_gutter = format!("{:gutter_width$} ", ""); + row_spans.push(RtSpan::styled(cont_gutter, style_gutter())); + } + row_spans.extend(chunk); + lines.push(RtLine::from(row_spans)); + } + lines } -fn line_number_width(max_line_number: usize) -> usize { +/// Split styled spans into chunks that fit within `max_cols` display columns. +/// +/// Returns one `Vec` per output line. Styles are preserved across +/// split boundaries so that wrapping never loses syntax coloring. +/// +/// The algorithm walks characters using their Unicode display width (with tabs +/// expanded to [`TAB_WIDTH`] columns). When a character would overflow the +/// current line, the accumulated text is flushed and a new line begins. A +/// single character wider than the remaining space forces a line break *before* +/// the character so that progress is always made (avoiding infinite loops on +/// CJK characters or tabs at the end of a line). +fn wrap_styled_spans(spans: &[RtSpan<'static>], max_cols: usize) -> Vec>> { + let mut result: Vec>> = Vec::new(); + let mut current_line: Vec> = Vec::new(); + let mut col: usize = 0; + + for span in spans { + let style = span.style; + let text = span.content.as_ref(); + let mut remaining = text; + + while !remaining.is_empty() { + // Accumulate characters until we fill the line. + let mut byte_end = 0; + let mut chars_col = 0; + + for ch in remaining.chars() { + // Tabs have no Unicode width; treat them as TAB_WIDTH columns. + let w = ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 0 }); + if col + chars_col + w > max_cols { + // Adding this character would exceed the line width. + // Break here; if this is the first character in `remaining` + // we will flush/start a new line in the `byte_end == 0` + // branch below before consuming it. + break; + } + byte_end += ch.len_utf8(); + chars_col += w; + } + + if byte_end == 0 { + // Single character wider than remaining space — force onto a + // new line so we make progress. + if !current_line.is_empty() { + result.push(std::mem::take(&mut current_line)); + } + // Take at least one character to avoid an infinite loop. + let Some(ch) = remaining.chars().next() else { + break; + }; + let ch_len = ch.len_utf8(); + current_line.push(RtSpan::styled(remaining[..ch_len].to_string(), style)); + // Use fallback width 1 (not 0) so this branch always advances + // even if `ch` has unknown/zero display width. + col = ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 1 }); + remaining = &remaining[ch_len..]; + continue; + } + + let (chunk, rest) = remaining.split_at(byte_end); + current_line.push(RtSpan::styled(chunk.to_string(), style)); + col += chars_col; + remaining = rest; + + // If we exactly filled or exceeded the line, start a new one. + // Do not gate on !remaining.is_empty() — the next span in the + // outer loop may still have content that must start on a fresh line. + if col >= max_cols { + result.push(std::mem::take(&mut current_line)); + col = 0; + } + } + } + + // Push the last line (always at least one, even if empty). + if !current_line.is_empty() || result.is_empty() { + result.push(current_line); + } + + result +} + +pub(crate) fn line_number_width(max_line_number: usize) -> usize { if max_line_number == 0 { 1 } else { @@ -454,6 +746,19 @@ mod tests { assert_snapshot!(name, terminal.backend()); } + fn display_width(text: &str) -> usize { + text.chars() + .map(|ch| ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 0 })) + .sum() + } + + fn line_display_width(line: &RtLine<'static>) -> usize { + line.spans + .iter() + .map(|span| display_width(span.content.as_ref())) + .sum() + } + fn snapshot_lines_text(name: &str, lines: &[RtLine<'static>]) { // Convert Lines to plain text rows and trim trailing spaces so it's // easier to validate indentation visually in snapshots. @@ -471,6 +776,71 @@ mod tests { assert_snapshot!(name, text); } + fn diff_gallery_changes() -> HashMap { + let mut changes: HashMap = HashMap::new(); + + let rust_original = + "fn greet(name: &str) {\n println!(\"hello\");\n println!(\"bye\");\n}\n"; + let rust_modified = "fn greet(name: &str) {\n println!(\"hello {name}\");\n println!(\"emoji: 🚀✨ and CJK: 你好世界\");\n}\n"; + let rust_patch = diffy::create_patch(rust_original, rust_modified).to_string(); + changes.insert( + PathBuf::from("src/lib.rs"), + FileChange::Update { + unified_diff: rust_patch, + move_path: None, + }, + ); + + let py_original = "def add(a, b):\n\treturn a + b\n\nprint(add(1, 2))\n"; + let py_modified = "def add(a, b):\n\treturn a + b + 42\n\nprint(add(1, 2))\n"; + let py_patch = diffy::create_patch(py_original, py_modified).to_string(); + changes.insert( + PathBuf::from("scripts/calc.txt"), + FileChange::Update { + unified_diff: py_patch, + move_path: Some(PathBuf::from("scripts/calc.py")), + }, + ); + + changes.insert( + PathBuf::from("assets/banner.txt"), + FileChange::Add { + content: "HEADER\tVALUE\nrocket\t🚀\ncity\t東京\n".to_string(), + }, + ); + changes.insert( + PathBuf::from("examples/new_sample.rs"), + FileChange::Add { + content: "pub fn greet(name: &str) {\n println!(\"Hello, {name}!\");\n}\n" + .to_string(), + }, + ); + + changes.insert( + PathBuf::from("tmp/obsolete.log"), + FileChange::Delete { + content: "old line 1\nold line 2\nold line 3\n".to_string(), + }, + ); + changes.insert( + PathBuf::from("legacy/old_script.py"), + FileChange::Delete { + content: "def legacy(x):\n return x + 1\nprint(legacy(3))\n".to_string(), + }, + ); + + changes + } + + fn snapshot_diff_gallery(name: &str, width: u16, height: u16) { + let lines = create_diff_summary( + &diff_gallery_changes(), + &PathBuf::from("/"), + usize::from(width), + ); + snapshot_lines(name, lines, width, height); + } + #[test] fn display_path_prefers_cwd_without_git_repo() { let cwd = if cfg!(windows) { @@ -694,4 +1064,367 @@ mod tests { snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10); } + + #[test] + fn ui_snapshot_syntax_highlighted_insert_wraps() { + // A long Rust line that exceeds 80 cols with syntax highlighting should + // wrap to multiple output lines rather than being clipped. + let long_rust = "fn very_long_function_name(arg_one: String, arg_two: String, arg_three: String, arg_four: String) -> Result> { Ok(arg_one) }"; + + let syntax_spans = + highlight_code_to_styled_spans(long_rust, "rust").expect("rust highlighting"); + let spans = &syntax_spans[0]; + + let lines = push_wrapped_diff_line_with_syntax( + 1, + DiffLineType::Insert, + long_rust, + 80, + line_number_width(1), + spans, + ); + + assert!( + lines.len() > 1, + "syntax-highlighted long line should wrap to multiple lines, got {}", + lines.len() + ); + + snapshot_lines("syntax_highlighted_insert_wraps", lines, 90, 10); + } + + #[test] + fn ui_snapshot_syntax_highlighted_insert_wraps_text() { + let long_rust = "fn very_long_function_name(arg_one: String, arg_two: String, arg_three: String, arg_four: String) -> Result> { Ok(arg_one) }"; + + let syntax_spans = + highlight_code_to_styled_spans(long_rust, "rust").expect("rust highlighting"); + let spans = &syntax_spans[0]; + + let lines = push_wrapped_diff_line_with_syntax( + 1, + DiffLineType::Insert, + long_rust, + 80, + line_number_width(1), + spans, + ); + + snapshot_lines_text("syntax_highlighted_insert_wraps_text", &lines); + } + + #[test] + fn ui_snapshot_diff_gallery_80x24() { + snapshot_diff_gallery("diff_gallery_80x24", 80, 24); + } + + #[test] + fn ui_snapshot_diff_gallery_94x35() { + snapshot_diff_gallery("diff_gallery_94x35", 94, 35); + } + + #[test] + fn ui_snapshot_diff_gallery_120x40() { + snapshot_diff_gallery("diff_gallery_120x40", 120, 40); + } + + #[test] + fn add_diff_uses_path_extension_for_highlighting() { + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("highlight_add.rs"), + FileChange::Add { + content: "pub fn sum(a: i32, b: i32) -> i32 { a + b }\n".to_string(), + }, + ); + + let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80); + let has_rgb = lines.iter().any(|line| { + line.spans + .iter() + .any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..)))) + }); + assert!( + has_rgb, + "add diff for .rs file should produce syntax-highlighted (RGB) spans" + ); + } + + #[test] + fn delete_diff_uses_path_extension_for_highlighting() { + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("highlight_delete.py"), + FileChange::Delete { + content: "def scale(x):\n return x * 2\n".to_string(), + }, + ); + + let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80); + let has_rgb = lines.iter().any(|line| { + line.spans + .iter() + .any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..)))) + }); + assert!( + has_rgb, + "delete diff for .py file should produce syntax-highlighted (RGB) spans" + ); + } + + #[test] + fn detect_lang_for_common_paths() { + // Standard extensions are detected. + assert!(detect_lang_for_path(Path::new("foo.rs")).is_some()); + assert!(detect_lang_for_path(Path::new("bar.py")).is_some()); + assert!(detect_lang_for_path(Path::new("app.tsx")).is_some()); + + // Extensionless files return None. + assert!(detect_lang_for_path(Path::new("Makefile")).is_none()); + assert!(detect_lang_for_path(Path::new("randomfile")).is_none()); + } + + #[test] + fn wrap_styled_spans_single_line() { + // Content that fits in one line should produce exactly one chunk. + let spans = vec![RtSpan::raw("short")]; + let result = wrap_styled_spans(&spans, 80); + assert_eq!(result.len(), 1); + } + + #[test] + fn wrap_styled_spans_splits_long_content() { + // Content wider than max_cols should produce multiple chunks. + let long_text = "a".repeat(100); + let spans = vec![RtSpan::raw(long_text)]; + let result = wrap_styled_spans(&spans, 40); + assert!( + result.len() >= 3, + "100 chars at 40 cols should produce at least 3 lines, got {}", + result.len() + ); + } + + #[test] + fn wrap_styled_spans_flushes_at_span_boundary() { + // When span A fills exactly to max_cols and span B follows, the line + // must be flushed before B starts. Otherwise B's first character lands + // on an already-full line, producing over-width output. + let style_a = Style::default().fg(Color::Red); + let style_b = Style::default().fg(Color::Blue); + let spans = vec![ + RtSpan::styled("aaaa", style_a), // 4 cols, fills line exactly at max_cols=4 + RtSpan::styled("bb", style_b), // should start on a new line + ]; + let result = wrap_styled_spans(&spans, 4); + assert_eq!( + result.len(), + 2, + "span ending exactly at max_cols should flush before next span: {result:?}" + ); + // First line should only contain the 'a' span. + let first_width: usize = result[0].iter().map(|s| s.content.chars().count()).sum(); + assert!( + first_width <= 4, + "first line should be at most 4 cols wide, got {first_width}" + ); + } + + #[test] + fn wrap_styled_spans_preserves_styles() { + // Verify that styles survive split boundaries. + let style = Style::default().fg(Color::Green); + let text = "x".repeat(50); + let spans = vec![RtSpan::styled(text, style)]; + let result = wrap_styled_spans(&spans, 20); + for chunk in &result { + for span in chunk { + assert_eq!(span.style, style, "style should be preserved across wraps"); + } + } + } + + #[test] + fn wrap_styled_spans_tabs_have_visible_width() { + // A tab should count as TAB_WIDTH columns, not zero. + // With max_cols=8, a tab (4 cols) + "abcde" (5 cols) = 9 cols → must wrap. + let spans = vec![RtSpan::raw("\tabcde")]; + let result = wrap_styled_spans(&spans, 8); + assert!( + result.len() >= 2, + "tab + 5 chars should exceed 8 cols and wrap, got {} line(s): {result:?}", + result.len() + ); + } + + #[test] + fn wrap_styled_spans_wraps_before_first_overflowing_char() { + let spans = vec![RtSpan::raw("abcd\t界")]; + let result = wrap_styled_spans(&spans, 5); + + let line_text: Vec = result + .iter() + .map(|line| { + line.iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + assert_eq!(line_text, vec!["abcd", "\t", "界"]); + + let line_width = |line: &[RtSpan<'static>]| -> usize { + line.iter() + .flat_map(|span| span.content.chars()) + .map(|ch| ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 0 })) + .sum() + }; + for line in &result { + assert!( + line_width(line) <= 5, + "wrapped line exceeded width 5: {line:?}" + ); + } + } + + #[test] + fn fallback_wrapping_uses_display_width_for_tabs_and_wide_chars() { + let width = 8; + let lines = push_wrapped_diff_line( + 1, + DiffLineType::Insert, + "abcd\t界🙂", + width, + line_number_width(1), + ); + + assert!(lines.len() >= 2, "expected wrapped output, got {lines:?}"); + for line in &lines { + assert!( + line_display_width(line) <= width, + "fallback wrapped line exceeded width {width}: {line:?}" + ); + } + } + + #[test] + fn large_update_diff_skips_highlighting() { + // Build a patch large enough to exceed MAX_HIGHLIGHT_LINES (10_000). + // Without the pre-check this would attempt 10k+ parser initializations. + let line_count = 10_500; + let original: String = (0..line_count).map(|i| format!("line {i}\n")).collect(); + let modified: String = (0..line_count) + .map(|i| { + if i % 2 == 0 { + format!("line {i} changed\n") + } else { + format!("line {i}\n") + } + }) + .collect(); + let patch = diffy::create_patch(&original, &modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("huge.rs"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + // Should complete quickly (no per-line parser init). If guardrails + // are bypassed this would be extremely slow. + let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80); + + // The diff rendered without timing out — the guardrails prevented + // thousands of per-line parser initializations. Verify we actually + // got output (the patch is non-empty). + assert!( + lines.len() > 100, + "expected many output lines from large diff, got {}", + lines.len(), + ); + + // No span should contain an RGB foreground color (syntax themes + // produce RGB; plain diff styles only use named Color variants). + for line in &lines { + for span in &line.spans { + if let Some(ratatui::style::Color::Rgb(..)) = span.style.fg { + panic!( + "large diff should not have syntax-highlighted spans, \ + got RGB color in style {:?} for {:?}", + span.style, span.content, + ); + } + } + } + } + + #[test] + fn rename_diff_uses_destination_extension_for_highlighting() { + // A rename from an unknown extension to .rs should highlight as Rust. + // Without the fix, detect_lang_for_path uses the source path (.xyzzy), + // which has no syntax definition, so highlighting is skipped. + let original = "fn main() {}\n"; + let modified = "fn main() { println!(\"hi\"); }\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("foo.xyzzy"), + FileChange::Update { + unified_diff: patch, + move_path: Some(PathBuf::from("foo.rs")), + }, + ); + + let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80); + let has_rgb = lines.iter().any(|line| { + line.spans + .iter() + .any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..)))) + }); + assert!( + has_rgb, + "rename from .xyzzy to .rs should produce syntax-highlighted (RGB) spans" + ); + } + + #[test] + fn update_diff_preserves_multiline_highlight_state_within_hunk() { + let original = "fn demo() {\n let s = \"hello\";\n}\n"; + let modified = "fn demo() {\n let s = \"hello\nworld\";\n}\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("demo.rs"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let expected_multiline = + highlight_code_to_styled_spans(" let s = \"hello\nworld\";\n", "rust") + .expect("rust highlighting"); + let expected_style = expected_multiline + .get(1) + .and_then(|line| { + line.iter() + .find(|span| span.content.as_ref().contains("world")) + }) + .map(|span| span.style) + .expect("expected highlighted span for second multiline string line"); + + let lines = create_diff_summary(&changes, &PathBuf::from("/"), 120); + let actual_style = lines + .iter() + .flat_map(|line| line.spans.iter()) + .find(|span| span.content.as_ref().contains("world")) + .map(|span| span.style) + .expect("expected rendered diff span containing 'world'"); + + assert_eq!(actual_style, expected_style); + } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c44b5eb751..87d7089591 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -105,6 +105,7 @@ mod streaming; mod style; mod terminal_palette; mod text_formatting; +mod theme_picker; mod tooltips; mod tui; mod ui_consts; @@ -545,6 +546,7 @@ async fn run_ratatui_app( } else { initial_config }; + let mut missing_session_exit = |id_str: &str, action: &str| { error!("Error finding conversation path: {id_str}"); restore(); @@ -693,7 +695,7 @@ async fn run_ratatui_app( None => None, }; - let config = match &session_selection { + let mut config = match &session_selection { resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => { load_config_or_exit_with_fallback_cwd( cli_kv_overrides.clone(), @@ -705,6 +707,17 @@ async fn run_ratatui_app( } _ => config, }; + + // Configure syntax highlighting theme from the final config — onboarding + // and resume/fork can both reload config with a different tui_theme, so + // this must happen after the last possible reload. + if let Some(w) = crate::render::highlight::set_theme_override( + config.tui_theme.clone(), + find_codex_home().ok(), + ) { + config.startup_warnings.push(w); + } + set_default_client_residency_requirement(config.enforce_residency.value()); let active_profile = config.active_profile.clone(); let should_show_trust_screen = should_show_trust_screen(&config); @@ -1186,6 +1199,50 @@ trust_level = "untrusted" Ok(()) } + /// Regression: theme must be configured from the *final* config. + /// + /// `run_ratatui_app` can reload config during onboarding and again + /// during session resume/fork. The syntax theme override (stored in + /// a `OnceLock`) must use the final config's `tui_theme`, not the + /// initial one — otherwise users resuming a thread in a project with + /// a different theme get the wrong highlighting. + /// + /// We verify the invariant indirectly: `validate_theme_name` (the + /// pure validation core of `set_theme_override`) must be called with + /// the *final* config's theme, and its warning must land in the + /// final config's `startup_warnings`. + #[tokio::test] + async fn theme_warning_uses_final_config() -> std::io::Result<()> { + use crate::render::highlight::validate_theme_name; + + let temp_dir = TempDir::new()?; + + // initial_config has a valid theme — no warning. + let initial_config = build_config(&temp_dir).await?; + assert!(initial_config.tui_theme.is_none()); + + // Simulate resume/fork reload: the final config has an invalid theme. + let mut config = build_config(&temp_dir).await?; + config.tui_theme = Some("bogus-theme".into()); + + // Theme override must use the final config (not initial_config). + // This mirrors the real call site in run_ratatui_app. + if let Some(w) = validate_theme_name(config.tui_theme.as_deref(), Some(temp_dir.path())) { + config.startup_warnings.push(w); + } + + assert_eq!( + config.startup_warnings.len(), + 1, + "warning from final config's invalid theme should be present" + ); + assert!( + config.startup_warnings[0].contains("bogus-theme"), + "warning should reference the final config's theme name" + ); + Ok(()) + } + #[tokio::test] async fn read_session_cwd_falls_back_to_session_meta() -> std::io::Result<()> { let temp_dir = TempDir::new()?; diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 35b0e8f5fe..ba8b47d5ed 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -1,3 +1,4 @@ +use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; @@ -99,6 +100,8 @@ where pending_marker_line: bool, in_paragraph: bool, in_code_block: bool, + code_block_lang: Option, + code_block_buffer: String, wrap_width: Option, current_line_content: Option>, current_initial_indent: Vec>, @@ -124,6 +127,8 @@ where pending_marker_line: false, in_paragraph: false, in_code_block: false, + code_block_lang: None, + code_block_buffer: String::new(), wrap_width, current_line_content: None, current_initial_indent: Vec::new(), @@ -278,6 +283,16 @@ where self.push_line(Line::default()); } self.pending_marker_line = false; + + // When inside a fenced code block with a known language, accumulate + // text into the buffer for batch highlighting in end_codeblock(). + // Append verbatim — pulldown-cmark text events already contain the + // original line breaks, so inserting separators would double them. + if self.in_code_block && self.code_block_lang.is_some() { + self.code_block_buffer.push_str(&text); + return; + } + if self.in_code_block && !self.needs_newline { let has_content = self .current_line_content @@ -394,12 +409,25 @@ where self.needs_newline = false; } - fn start_codeblock(&mut self, _lang: Option, indent: Option>) { + fn start_codeblock(&mut self, lang: Option, indent: Option>) { self.flush_current_line(); if !self.text.lines.is_empty() { self.push_blank_line(); } self.in_code_block = true; + + // Extract the language token from the info string. CommonMark info + // strings can contain metadata after the language, separated by commas, + // spaces, or other delimiters (e.g. "rust,no_run", "rust title=demo"). + // Take only the first token so the syntax lookup succeeds. + let lang = lang + .as_deref() + .and_then(|s| s.split([',', ' ', '\t']).next()) + .filter(|s| !s.is_empty()) + .map(std::string::ToString::to_string); + self.code_block_lang = lang; + self.code_block_buffer.clear(); + self.indent_stack.push(IndentContext::new( vec![indent.unwrap_or_default()], None, @@ -409,6 +437,20 @@ where } fn end_codeblock(&mut self) { + // If we buffered code for a known language, syntax-highlight it now. + if let Some(lang) = self.code_block_lang.take() { + let code = std::mem::take(&mut self.code_block_buffer); + if !code.is_empty() { + let highlighted = highlight_code_to_lines(&code, &lang); + for hl_line in highlighted { + self.push_line(Line::default()); + for span in hl_line.spans { + self.push_span(span); + } + } + } + } + self.needs_newline = true; self.in_code_block = false; self.indent_stack.pop(); @@ -689,4 +731,39 @@ mod tests { "expected full URL-like token in one rendered line, got: {lines:?}" ); } + + #[test] + fn fenced_code_info_string_with_metadata_highlights() { + // CommonMark info strings like "rust,no_run" or "rust title=demo" + // contain metadata after the language token. The language must be + // extracted (first word / comma-separated token) so highlighting works. + for info in &["rust,no_run", "rust no_run", "rust title=\"demo\""] { + let markdown = format!("```{info}\nfn main() {{}}\n```\n"); + let rendered = render_markdown_text(&markdown); + let has_rgb = rendered.lines.iter().any(|line| { + line.spans + .iter() + .any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..)))) + }); + assert!( + has_rgb, + "info string \"{info}\" should still produce syntax highlighting" + ); + } + } + + #[test] + fn crlf_code_block_no_extra_blank_lines() { + // pulldown-cmark can split CRLF code blocks into multiple Text events. + // The buffer must concatenate them verbatim — no inserted separators. + let markdown = "```rust\r\nfn main() {}\r\n line2\r\n```\r\n"; + let rendered = render_markdown_text(markdown); + let lines = lines_to_strings(&rendered); + // Should be exactly two code lines; no spurious blank line between them. + assert_eq!( + lines, + vec!["fn main() {}".to_string(), " line2".to_string()], + "CRLF code block should not produce extra blank lines: {lines:?}" + ); + } } diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index c549699695..68daaaab7f 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -652,10 +652,83 @@ fn link() { } #[test] -fn code_block_unhighlighted() { +fn code_block_known_lang_has_syntax_colors() { let text = render_markdown_text("```rust\nfn main() {}\n```\n"); - let expected = Text::from_iter([Line::from_iter(["", "fn main() {}"])]); - assert_eq!(text, expected); + let content: Vec = text + .lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.clone()) + .collect::() + }) + .collect(); + // Content should be preserved; ignore trailing empty line from highlighting. + let content: Vec<&str> = content + .iter() + .map(std::string::String::as_str) + .filter(|s| !s.is_empty()) + .collect(); + assert_eq!(content, vec!["fn main() {}"]); + + // At least one span should have non-default style (syntax highlighting). + let has_colored_span = text + .lines + .iter() + .flat_map(|l| l.spans.iter()) + .any(|sp| sp.style.fg.is_some()); + assert!(has_colored_span, "expected syntax-highlighted spans with color"); +} + +#[test] +fn code_block_unknown_lang_plain() { + let text = render_markdown_text("```xyzlang\nhello world\n```\n"); + let content: Vec = text + .lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.clone()) + .collect::() + }) + .collect(); + let content: Vec<&str> = content + .iter() + .map(std::string::String::as_str) + .filter(|s| !s.is_empty()) + .collect(); + assert_eq!(content, vec!["hello world"]); + + // No syntax coloring for unknown language — all spans have default style. + let has_colored_span = text + .lines + .iter() + .flat_map(|l| l.spans.iter()) + .any(|sp| sp.style.fg.is_some()); + assert!(!has_colored_span, "expected no syntax coloring for unknown lang"); +} + +#[test] +fn code_block_no_lang_plain() { + let text = render_markdown_text("```\nno lang specified\n```\n"); + let content: Vec = text + .lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.clone()) + .collect::() + }) + .collect(); + let content: Vec<&str> = content + .iter() + .map(std::string::String::as_str) + .filter(|s| !s.is_empty()) + .collect(); + assert_eq!(content, vec!["no lang specified"]); } #[test] @@ -721,16 +794,25 @@ Here is a code block that shows another fenced block: .collect::() }) .collect(); + // Filter empty trailing lines for stability; the code block may or may + // not emit a trailing blank depending on the highlighting path. + let trimmed: Vec<&str> = { + let mut v: Vec<&str> = lines.iter().map(std::string::String::as_str).collect(); + while v.last() == Some(&"") { + v.pop(); + } + v + }; assert_eq!( - lines, + trimmed, vec![ - "Here is a code block that shows another fenced block:".to_string(), - String::new(), - "```md".to_string(), - "# Inside fence".to_string(), - "- bullet".to_string(), - "- `inline code`".to_string(), - "```".to_string(), + "Here is a code block that shows another fenced block:", + "", + "```md", + "# Inside fence", + "- bullet", + "- `inline code`", + "```", ] ); } @@ -993,3 +1075,36 @@ fn nested_item_continuation_paragraph_is_indented() { ]); assert_eq!(text, expected); } + +#[test] +fn code_block_preserves_trailing_blank_lines() { + // A fenced code block with an intentional trailing blank line must keep it. + let md = "```rust\nfn main() {}\n\n```\n"; + let text = render_markdown_text(md); + let content: Vec = text + .lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.clone()) + .collect::() + }) + .collect(); + // Should have: "fn main() {}" then "" (the blank line). + // Filter only to content lines (skip leading/trailing empty from rendering). + assert!( + content.iter().any(|c| c == "fn main() {}"), + "expected code line, got {content:?}" + ); + // The trailing blank line inside the fence should be preserved. + let code_start = content.iter().position(|c| c == "fn main() {}").unwrap(); + assert!( + content.len() > code_start + 1, + "expected a line after 'fn main() {{}}' but content ends: {content:?}" + ); + assert_eq!( + content[code_start + 1], "", + "trailing blank line inside code fence was lost: {content:?}" + ); +} diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index e6d200cc3d..b0898ffe0a 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -1,154 +1,574 @@ +//! Syntax highlighting engine for the TUI. +//! +//! Wraps [syntect] with the [two_face] grammar and theme bundles to provide +//! ~250-language syntax highlighting and 32 bundled color themes. The module +//! owns four process-global singletons: +//! +//! | Singleton | Type | Purpose | +//! |---|---|---| +//! | `SYNTAX_SET` | `OnceLock` | Grammar database, immutable after init | +//! | `THEME` | `OnceLock>` | Active color theme, swappable at runtime | +//! | `THEME_OVERRIDE` | `OnceLock>` | Persisted user preference (write-once) | +//! | `CODEX_HOME` | `OnceLock>` | Root for custom `.tmTheme` discovery | +//! +//! **Lifecycle:** call [`set_theme_override`] once at startup (after the final +//! config is resolved) to persist the user preference and seed the `THEME` +//! lock. After that, [`set_syntax_theme`] and [`current_syntax_theme`] can +//! swap/snapshot the theme for live preview. All highlighting functions read +//! the theme via `theme_lock()`. +//! +//! **Guardrails:** inputs exceeding 512 KB or 10 000 lines are rejected early +//! (returns `None`) to prevent pathological CPU/memory usage. Callers must +//! fall back to plain unstyled text. + +use ratatui::style::Color as RtColor; +use ratatui::style::Modifier; use ratatui::style::Style; -use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; +use std::path::Path; +use std::path::PathBuf; use std::sync::OnceLock; -use tree_sitter_highlight::Highlight; -use tree_sitter_highlight::HighlightConfiguration; -use tree_sitter_highlight::HighlightEvent; -use tree_sitter_highlight::Highlighter; +use std::sync::RwLock; +use syntect::easy::HighlightLines; +use syntect::highlighting::FontStyle; +use syntect::highlighting::Style as SyntectStyle; +use syntect::highlighting::Theme; +use syntect::highlighting::ThemeSet; +use syntect::parsing::SyntaxReference; +use syntect::parsing::SyntaxSet; +use syntect::util::LinesWithEndings; +use two_face::theme::EmbeddedThemeName; -// Ref: https://github.com/tree-sitter/tree-sitter-bash/blob/master/queries/highlights.scm -#[derive(Copy, Clone)] -enum BashHighlight { - Comment, - Constant, - Embedded, - Function, - Keyword, - Number, - Operator, - Property, - String, +// -- Global singletons ------------------------------------------------------- + +static SYNTAX_SET: OnceLock = OnceLock::new(); +static THEME: OnceLock> = OnceLock::new(); +static THEME_OVERRIDE: OnceLock> = OnceLock::new(); +static CODEX_HOME: OnceLock> = OnceLock::new(); + +fn syntax_set() -> &'static SyntaxSet { + SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines) } -impl BashHighlight { - const ALL: [Self; 9] = [ - Self::Comment, - Self::Constant, - Self::Embedded, - Self::Function, - Self::Keyword, - Self::Number, - Self::Operator, - Self::Property, - Self::String, - ]; - - const fn as_str(self) -> &'static str { - match self { - Self::Comment => "comment", - Self::Constant => "constant", - Self::Embedded => "embedded", - Self::Function => "function", - Self::Keyword => "keyword", - Self::Number => "number", - Self::Operator => "operator", - Self::Property => "property", - Self::String => "string", +/// Set the user-configured syntax theme override and codex home path. +/// +/// Call this with the **final resolved config** (after onboarding, resume, and +/// fork reloads complete). The first call persists `name` and `codex_home` in +/// `OnceLock`s used by startup/default theme resolution. +/// +/// Subsequent calls cannot change the persisted `OnceLock` values, but they +/// still update the runtime theme immediately for live preview flows. +/// +/// Returns a warning message when the configured theme name cannot be +/// resolved to a bundled theme or a custom `.tmTheme` file on disk. +/// The caller should surface this via `Config::startup_warnings` so it +/// appears as a `⚠` banner in the TUI. +pub(crate) fn set_theme_override( + name: Option, + codex_home: Option, +) -> Option { + let mut warning = validate_theme_name(name.as_deref(), codex_home.as_deref()); + let override_set_ok = THEME_OVERRIDE.set(name.clone()).is_ok(); + let codex_home_set_ok = CODEX_HOME.set(codex_home.clone()).is_ok(); + if THEME.get().is_some() { + set_syntax_theme(resolve_theme_with_override( + name.as_deref(), + codex_home.as_deref(), + )); + } + if !override_set_ok || !codex_home_set_ok { + let duplicate_msg = "Ignoring duplicate or late syntax theme override persistence; runtime theme was updated from the latest override, but persisted override config can only be initialized once."; + tracing::warn!("{duplicate_msg}"); + if warning.is_none() { + warning = Some(duplicate_msg.to_string()); } } + warning +} - fn style(self) -> Style { - match self { - Self::Comment | Self::Operator | Self::String => Style::default().dim(), - _ => Style::default(), - } +/// Check whether a theme name resolves to a bundled theme or a custom +/// `.tmTheme` file. Returns a user-facing warning when it does not. +pub(crate) fn validate_theme_name(name: Option<&str>, codex_home: Option<&Path>) -> Option { + let name = name?; + let custom_theme_path_display = codex_home + .map(|home| custom_theme_path(name, home).display().to_string()) + .unwrap_or_else(|| format!("$CODEX_HOME/themes/{name}.tmTheme")); + // Bundled themes always resolve. + if parse_theme_name(name).is_some() { + return None; } -} - -static HIGHLIGHT_CONFIG: OnceLock = OnceLock::new(); - -fn highlight_names() -> &'static [&'static str] { - static NAMES: OnceLock<[&'static str; BashHighlight::ALL.len()]> = OnceLock::new(); - NAMES - .get_or_init(|| BashHighlight::ALL.map(BashHighlight::as_str)) - .as_slice() -} - -fn highlight_config() -> &'static HighlightConfiguration { - HIGHLIGHT_CONFIG.get_or_init(|| { - let language = tree_sitter_bash::LANGUAGE.into(); - #[expect(clippy::expect_used)] - let mut config = HighlightConfiguration::new( - language, - "bash", - tree_sitter_bash::HIGHLIGHT_QUERY, - "", - "", - ) - .expect("load bash highlight query"); - config.configure(highlight_names()); - config - }) -} - -fn highlight_for(highlight: Highlight) -> BashHighlight { - BashHighlight::ALL[highlight.0] -} - -fn push_segment(lines: &mut Vec>, segment: &str, style: Option