From e4eedd6170580d5b06fb539635a78f261a6b7369 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Fri, 20 Mar 2026 23:36:58 -0700 Subject: [PATCH 01/38] Code mode on v8 (#15276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves Code Mode to a new crate with no dependencies on codex. This create encodes the code mode semantics that we want for lifetime, mounting, tool calling. The model-facing surface is mostly unchanged. `exec` still runs raw JavaScript, `wait` still resumes or terminates a `cell_id`, nested tools are still available through `tools.*`, and helpers like `text`, `image`, `store`, `load`, `notify`, `yield_control`, and `exit` still exist. The major change is underneath that surface: - Old code mode was an external Node runtime. - New code mode is an in-process V8 runtime embedded directly in Rust. - Old code mode managed cells inside a long-lived Node runner process. - New code mode manages cells in Rust, with one V8 runtime thread per active `exec`. - Old code mode used JSON protocol messages over child stdin/stdout plus Node worker-thread messages. - New code mode uses Rust channels and direct V8 callbacks/events. This PR also fixes the two migration regressions that fell out of that substrate change: - `wait { terminate: true }` now waits for the V8 runtime to actually stop before reporting termination. - synchronous top-level `exit()` now succeeds again instead of surfacing as a script error. --- - `core/src/tools/code_mode/*` is now mostly an adapter layer for the public `exec` / `wait` tools. - `code-mode/src/service.rs` owns cell sessions and async control flow in Rust. - `code-mode/src/runtime/*.rs` owns the embedded V8 isolate and JavaScript execution. - each `exec` spawns a dedicated runtime thread plus a Rust session-control task. - helper globals are installed directly into the V8 context instead of being injected through a source prelude. - helper modules like `tools.js` and `@openai/code_mode` are synthesized through V8 module resolution callbacks in Rust. --- Also added a benchmark for showing the speed of init and use of a code mode env: ``` $ cargo bench -p codex-code-mode --bench exec_overhead -- --samples 30 --warm-iterations 25 --tool-counts 0,32,128 Finished [`bench` profile [optimized]](https://doc.rust-lang.org/cargo/reference/profiles.html#default-profiles) target(s) in 0.18s Running benches/exec_overhead.rs (target/release/deps/exec_overhead-008c440d800545ae) exec_overhead: samples=30, warm_iterations=25, tool_counts=[0, 32, 128] scenario tools samples warmups iters mean/exec p95/exec rssΔ p50 rssΔ max cold_exec 0 30 0 1 1.13ms 1.20ms 8.05MiB 8.06MiB warm_exec 0 30 1 25 473.43us 512.49us 912.00KiB 1.33MiB cold_exec 32 30 0 1 1.03ms 1.15ms 8.08MiB 8.11MiB warm_exec 32 30 1 25 509.73us 545.76us 960.00KiB 1.30MiB cold_exec 128 30 0 1 1.14ms 1.19ms 8.30MiB 8.34MiB warm_exec 128 30 1 25 575.08us 591.03us 736.00KiB 864.00KiB memory uses a fresh-process max RSS delta for each scenario ``` --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 15 + codex-rs/Cargo.toml | 4 +- codex-rs/code-mode/BUILD.bazel | 6 + codex-rs/code-mode/Cargo.toml | 25 + codex-rs/code-mode/src/description.rs | 555 +++++++++++ codex-rs/code-mode/src/lib.rs | 30 + codex-rs/code-mode/src/response.rs | 24 + codex-rs/code-mode/src/runtime/callbacks.rs | 209 ++++ codex-rs/code-mode/src/runtime/globals.rs | 138 +++ codex-rs/code-mode/src/runtime/mod.rs | 349 +++++++ .../code-mode/src/runtime/module_loader.rs | 235 +++++ codex-rs/code-mode/src/runtime/value.rs | 163 +++ codex-rs/code-mode/src/service.rs | 673 +++++++++++++ codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/tools/code_mode/bridge.js | 51 - .../core/src/tools/code_mode/description.md | 19 - .../src/tools/code_mode/execute_handler.rs | 189 +--- codex-rs/core/src/tools/code_mode/mod.rs | 360 +++---- codex-rs/core/src/tools/code_mode/process.rs | 173 ---- codex-rs/core/src/tools/code_mode/protocol.rs | 169 ---- .../src/tools/code_mode/response_adapter.rs | 44 + codex-rs/core/src/tools/code_mode/runner.cjs | 938 ------------------ codex-rs/core/src/tools/code_mode/service.rs | 108 -- .../src/tools/code_mode/wait_description.md | 8 - .../core/src/tools/code_mode/wait_handler.rs | 68 +- codex-rs/core/src/tools/code_mode/worker.rs | 116 --- .../core/src/tools/code_mode_description.rs | 298 +----- codex-rs/core/src/tools/router.rs | 3 +- codex-rs/core/src/tools/spec.rs | 12 +- codex-rs/core/tests/suite/code_mode.rs | 8 +- codex-rs/core/tests/suite/unified_exec.rs | 4 +- 31 files changed, 2730 insertions(+), 2265 deletions(-) create mode 100644 codex-rs/code-mode/BUILD.bazel create mode 100644 codex-rs/code-mode/Cargo.toml create mode 100644 codex-rs/code-mode/src/description.rs create mode 100644 codex-rs/code-mode/src/lib.rs create mode 100644 codex-rs/code-mode/src/response.rs create mode 100644 codex-rs/code-mode/src/runtime/callbacks.rs create mode 100644 codex-rs/code-mode/src/runtime/globals.rs create mode 100644 codex-rs/code-mode/src/runtime/mod.rs create mode 100644 codex-rs/code-mode/src/runtime/module_loader.rs create mode 100644 codex-rs/code-mode/src/runtime/value.rs create mode 100644 codex-rs/code-mode/src/service.rs delete mode 100644 codex-rs/core/src/tools/code_mode/bridge.js delete mode 100644 codex-rs/core/src/tools/code_mode/description.md delete mode 100644 codex-rs/core/src/tools/code_mode/process.rs delete mode 100644 codex-rs/core/src/tools/code_mode/protocol.rs create mode 100644 codex-rs/core/src/tools/code_mode/response_adapter.rs delete mode 100644 codex-rs/core/src/tools/code_mode/runner.cjs delete mode 100644 codex-rs/core/src/tools/code_mode/service.rs delete mode 100644 codex-rs/core/src/tools/code_mode/wait_description.md delete mode 100644 codex-rs/core/src/tools/code_mode/worker.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b3f8d88027..d0917ee38d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1800,6 +1800,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "codex-code-mode" +version = "0.0.0" +dependencies = [ + "async-trait", + "pretty_assertions", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "v8", +] + [[package]] name = "codex-config" version = "0.0.0" @@ -1857,6 +1871,7 @@ dependencies = [ "codex-arg0", "codex-artifacts", "codex-async-utils", + "codex-code-mode", "codex-config", "codex-connectors", "codex-exec-server", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 6d768d6963..524b61e3b8 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -13,6 +13,7 @@ members = [ "feedback", "features", "codex-backend-openapi-models", + "code-mode", "cloud-requirements", "cloud-tasks", "cloud-tasks-client", @@ -91,6 +92,7 @@ app_test_support = { path = "app-server/tests/common" } codex-ansi-escape = { path = "ansi-escape" } codex-api = { path = "codex-api" } codex-artifacts = { path = "artifacts" } +codex-code-mode = { path = "code-mode" } codex-package-manager = { path = "package-manager" } codex-app-server = { path = "app-server" } codex-app-server-client = { path = "app-server-client" } @@ -374,7 +376,7 @@ ignored = [ "openssl-sys", "codex-utils-readiness", "codex-secrets", - "codex-v8-poc" + "codex-v8-poc", ] [profile.release] diff --git a/codex-rs/code-mode/BUILD.bazel b/codex-rs/code-mode/BUILD.bazel new file mode 100644 index 0000000000..bf39d9d5a5 --- /dev/null +++ b/codex-rs/code-mode/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "code-mode", + crate_name = "codex_code_mode", +) diff --git a/codex-rs/code-mode/Cargo.toml b/codex-rs/code-mode/Cargo.toml new file mode 100644 index 0000000000..e821ca0e4d --- /dev/null +++ b/codex-rs/code-mode/Cargo.toml @@ -0,0 +1,25 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-code-mode" +version.workspace = true + +[lib] +doctest = false +name = "codex_code_mode" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +tokio-util = { workspace = true, features = ["rt"] } +tracing = { workspace = true } +v8 = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs new file mode 100644 index 0000000000..c875e2a1b1 --- /dev/null +++ b/codex-rs/code-mode/src/description.rs @@ -0,0 +1,555 @@ +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; + +use crate::PUBLIC_TOOL_NAME; + +const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; +const CODE_MODE_ONLY_PREFACE: &str = + "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; +const EXEC_DESCRIPTION_TEMPLATE: &str = r#"## exec +- Runs raw JavaScript in an isolated context (no Node, no file system, or network access, no console). +- Send raw JavaScript source text, not JSON, quoted strings, or markdown code fences. +- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`. +- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running. +- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens. +- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. +- Tool methods take either string or object as parameter. +- They return either a structured value or a string based on the description above. + +- Global helpers: +- `exit()`: Immediately ends the current script successfully (like an early return from the top level). +- `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible. +- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null })`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. +- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. +- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. +- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. +- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries. +- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#; +const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`. +- `cell_id` identifies the running `exec` cell to resume. +- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout. +- `max_tokens` limits how much new output this wait call returns. +- `terminate: true` stops the running cell instead of waiting for more output. +- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell. +- If the cell is still running, `wait` may yield again with the same `cell_id`. +- If the cell has already finished, `wait` returns the completed result and closes the cell."#; + +pub const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CodeModeToolKind { + Function, + Freeform, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ToolDefinition { + pub name: String, + pub description: String, + pub kind: CodeModeToolKind, + pub input_schema: Option, + pub output_schema: Option, +} + +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct CodeModeExecPragma { + #[serde(default)] + yield_time_ms: Option, + #[serde(default)] + max_output_tokens: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ParsedExecSource { + pub code: String, + pub yield_time_ms: Option, + pub max_output_tokens: Option, +} + +pub fn parse_exec_source(input: &str) -> Result { + if input.trim().is_empty() { + return Err( + "exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(), + ); + } + + let mut args = ParsedExecSource { + code: input.to_string(), + yield_time_ms: None, + max_output_tokens: None, + }; + + let mut lines = input.splitn(2, '\n'); + let first_line = lines.next().unwrap_or_default(); + let rest = lines.next().unwrap_or_default(); + let trimmed = first_line.trim_start(); + let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else { + return Ok(args); + }; + + if rest.trim().is_empty() { + return Err( + "exec pragma must be followed by JavaScript source on subsequent lines".to_string(), + ); + } + + let directive = pragma.trim(); + if directive.is_empty() { + return Err( + "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" + .to_string(), + ); + } + + let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| { + format!( + "exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}" + ) + })?; + let object = value.as_object().ok_or_else(|| { + "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" + .to_string() + })?; + for key in object.keys() { + match key.as_str() { + "yield_time_ms" | "max_output_tokens" => {} + _ => { + return Err(format!( + "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`" + )); + } + } + } + + let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| { + format!( + "exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}" + ) + })?; + if pragma + .yield_time_ms + .is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER) + { + return Err( + "exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(), + ); + } + if pragma.max_output_tokens.is_some_and(|max_output_tokens| { + u64::try_from(max_output_tokens) + .map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER) + .unwrap_or(true) + }) { + return Err( + "exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(), + ); + } + + args.code = rest.to_string(); + args.yield_time_ms = pragma.yield_time_ms; + args.max_output_tokens = pragma.max_output_tokens; + Ok(args) +} + +pub fn is_code_mode_nested_tool(tool_name: &str) -> bool { + tool_name != crate::PUBLIC_TOOL_NAME && tool_name != crate::WAIT_TOOL_NAME +} + +pub fn build_exec_tool_description( + enabled_tools: &[(String, String)], + code_mode_only: bool, +) -> String { + if !code_mode_only { + return EXEC_DESCRIPTION_TEMPLATE.to_string(); + } + + let mut sections = vec![ + CODE_MODE_ONLY_PREFACE.to_string(), + EXEC_DESCRIPTION_TEMPLATE.to_string(), + ]; + + if !enabled_tools.is_empty() { + let nested_tool_reference = enabled_tools + .iter() + .map(|(name, nested_description)| { + let global_name = normalize_code_mode_identifier(name); + format!( + "### `{global_name}` (`{name}`)\n{}", + nested_description.trim() + ) + }) + .collect::>() + .join("\n\n"); + sections.push(nested_tool_reference); + } + + sections.join("\n\n") +} + +pub fn build_wait_tool_description() -> &'static str { + WAIT_DESCRIPTION_TEMPLATE +} + +pub fn normalize_code_mode_identifier(tool_key: &str) -> String { + let mut identifier = String::new(); + + for (index, ch) in tool_key.chars().enumerate() { + let is_valid = if index == 0 { + ch == '_' || ch == '$' || ch.is_ascii_alphabetic() + } else { + ch == '_' || ch == '$' || ch.is_ascii_alphanumeric() + }; + + if is_valid { + identifier.push(ch); + } else { + identifier.push('_'); + } + } + + if identifier.is_empty() { + "_".to_string() + } else { + identifier + } +} + +pub fn augment_tool_definition(mut definition: ToolDefinition) -> ToolDefinition { + if definition.name != PUBLIC_TOOL_NAME { + definition.description = append_code_mode_sample_for_definition(&definition); + } + definition +} + +pub fn enabled_tool_metadata(definition: &ToolDefinition) -> EnabledToolMetadata { + EnabledToolMetadata { + tool_name: definition.name.clone(), + global_name: normalize_code_mode_identifier(&definition.name), + description: definition.description.clone(), + kind: definition.kind, + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct EnabledToolMetadata { + pub tool_name: String, + pub global_name: String, + pub description: String, + pub kind: CodeModeToolKind, +} + +pub fn append_code_mode_sample( + description: &str, + tool_name: &str, + input_name: &str, + input_type: String, + output_type: String, +) -> String { + let declaration = format!( + "declare const tools: {{ {} }};", + render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type) + ); + format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```") +} + +fn append_code_mode_sample_for_definition(definition: &ToolDefinition) -> String { + let input_name = match definition.kind { + CodeModeToolKind::Function => "args", + CodeModeToolKind::Freeform => "input", + }; + let input_type = match definition.kind { + CodeModeToolKind::Function => definition + .input_schema + .as_ref() + .map(render_json_schema_to_typescript) + .unwrap_or_else(|| "unknown".to_string()), + CodeModeToolKind::Freeform => "string".to_string(), + }; + let output_type = definition + .output_schema + .as_ref() + .map(render_json_schema_to_typescript) + .unwrap_or_else(|| "unknown".to_string()); + append_code_mode_sample( + &definition.description, + &definition.name, + input_name, + input_type, + output_type, + ) +} + +fn render_code_mode_tool_declaration( + tool_name: &str, + input_name: &str, + input_type: String, + output_type: String, +) -> String { + let tool_name = normalize_code_mode_identifier(tool_name); + format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;") +} + +pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String { + render_json_schema_to_typescript_inner(schema) +} + +fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String { + match schema { + JsonValue::Bool(true) => "unknown".to_string(), + JsonValue::Bool(false) => "never".to_string(), + JsonValue::Object(map) => { + if let Some(value) = map.get("const") { + return render_json_schema_literal(value); + } + + if let Some(values) = map.get("enum").and_then(JsonValue::as_array) { + let rendered = values + .iter() + .map(render_json_schema_literal) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + + for key in ["anyOf", "oneOf"] { + if let Some(variants) = map.get(key).and_then(JsonValue::as_array) { + let rendered = variants + .iter() + .map(render_json_schema_to_typescript_inner) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + } + + if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) { + let rendered = variants + .iter() + .map(render_json_schema_to_typescript_inner) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" & "); + } + } + + if let Some(schema_type) = map.get("type") { + if let Some(types) = schema_type.as_array() { + let rendered = types + .iter() + .filter_map(JsonValue::as_str) + .map(|schema_type| render_json_schema_type_keyword(map, schema_type)) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + + if let Some(schema_type) = schema_type.as_str() { + return render_json_schema_type_keyword(map, schema_type); + } + } + + if map.contains_key("properties") + || map.contains_key("additionalProperties") + || map.contains_key("required") + { + return render_json_schema_object(map); + } + + if map.contains_key("items") || map.contains_key("prefixItems") { + return render_json_schema_array(map); + } + + "unknown".to_string() + } + _ => "unknown".to_string(), + } +} + +fn render_json_schema_type_keyword( + map: &serde_json::Map, + schema_type: &str, +) -> String { + match schema_type { + "string" => "string".to_string(), + "number" | "integer" => "number".to_string(), + "boolean" => "boolean".to_string(), + "null" => "null".to_string(), + "array" => render_json_schema_array(map), + "object" => render_json_schema_object(map), + _ => "unknown".to_string(), + } +} + +fn render_json_schema_array(map: &serde_json::Map) -> String { + if let Some(items) = map.get("items") { + let item_type = render_json_schema_to_typescript_inner(items); + return format!("Array<{item_type}>"); + } + + if let Some(items) = map.get("prefixItems").and_then(JsonValue::as_array) { + let item_types = items + .iter() + .map(render_json_schema_to_typescript_inner) + .collect::>(); + if !item_types.is_empty() { + return format!("[{}]", item_types.join(", ")); + } + } + + "unknown[]".to_string() +} + +fn render_json_schema_object(map: &serde_json::Map) -> String { + let required = map + .get("required") + .and_then(JsonValue::as_array) + .map(|items| { + items + .iter() + .filter_map(JsonValue::as_str) + .collect::>() + }) + .unwrap_or_default(); + let properties = map + .get("properties") + .and_then(JsonValue::as_object) + .cloned() + .unwrap_or_default(); + + let mut sorted_properties = properties.iter().collect::>(); + sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); + let mut lines = sorted_properties + .into_iter() + .map(|(name, value)| { + let optional = if required.iter().any(|required_name| required_name == name) { + "" + } else { + "?" + }; + let property_name = render_json_schema_property_name(name); + let property_type = render_json_schema_to_typescript_inner(value); + format!("{property_name}{optional}: {property_type};") + }) + .collect::>(); + + if let Some(additional_properties) = map.get("additionalProperties") { + let property_type = match additional_properties { + JsonValue::Bool(true) => Some("unknown".to_string()), + JsonValue::Bool(false) => None, + value => Some(render_json_schema_to_typescript_inner(value)), + }; + + if let Some(property_type) = property_type { + lines.push(format!("[key: string]: {property_type};")); + } + } else if properties.is_empty() { + lines.push("[key: string]: unknown;".to_string()); + } + + if lines.is_empty() { + return "{}".to_string(); + } + + format!("{{ {} }}", lines.join(" ")) +} + +fn render_json_schema_property_name(name: &str) -> String { + if normalize_code_mode_identifier(name) == name { + name.to_string() + } else { + serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\""))) + } +} + +fn render_json_schema_literal(value: &JsonValue) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::CodeModeToolKind; + use super::ParsedExecSource; + use super::ToolDefinition; + use super::augment_tool_definition; + use super::build_exec_tool_description; + use super::normalize_code_mode_identifier; + use super::parse_exec_source; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn parse_exec_source_without_pragma() { + assert_eq!( + parse_exec_source("text('hi')").unwrap(), + ParsedExecSource { + code: "text('hi')".to_string(), + yield_time_ms: None, + max_output_tokens: None, + } + ); + } + + #[test] + fn parse_exec_source_with_pragma() { + assert_eq!( + parse_exec_source("// @exec: {\"yield_time_ms\": 10}\ntext('hi')").unwrap(), + ParsedExecSource { + code: "text('hi')".to_string(), + yield_time_ms: Some(10), + max_output_tokens: None, + } + ); + } + + #[test] + fn normalize_identifier_rewrites_invalid_characters() { + assert_eq!( + "mcp__ologs__get_profile", + normalize_code_mode_identifier("mcp__ologs__get_profile") + ); + assert_eq!( + "hidden_dynamic_tool", + normalize_code_mode_identifier("hidden-dynamic-tool") + ); + } + + #[test] + fn augment_tool_definition_appends_typed_declaration() { + let definition = ToolDefinition { + name: "hidden_dynamic_tool".to_string(), + description: "Test tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + "additionalProperties": false + })), + output_schema: Some(json!({ + "type": "object", + "properties": { "ok": { "type": "boolean" } }, + "required": ["ok"] + })), + }; + + let description = augment_tool_definition(definition).description; + assert!(description.contains("declare const tools")); + assert!( + description.contains( + "hidden_dynamic_tool(args: { city: string; }): Promise<{ ok: boolean; }>;" + ) + ); + } + + #[test] + fn code_mode_only_description_includes_nested_tools() { + let description = + build_exec_tool_description(&[("foo".to_string(), "bar".to_string())], true); + assert!(description.contains("### `foo` (`foo`)")); + } +} diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs new file mode 100644 index 0000000000..841e568be3 --- /dev/null +++ b/codex-rs/code-mode/src/lib.rs @@ -0,0 +1,30 @@ +mod description; +mod response; +mod runtime; +mod service; + +pub use description::CODE_MODE_PRAGMA_PREFIX; +pub use description::CodeModeToolKind; +pub use description::ToolDefinition; +pub use description::append_code_mode_sample; +pub use description::augment_tool_definition; +pub use description::build_exec_tool_description; +pub use description::build_wait_tool_description; +pub use description::is_code_mode_nested_tool; +pub use description::normalize_code_mode_identifier; +pub use description::parse_exec_source; +pub use description::render_json_schema_to_typescript; +pub use response::FunctionCallOutputContentItem; +pub use response::ImageDetail; +pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS; +pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL; +pub use runtime::DEFAULT_WAIT_YIELD_TIME_MS; +pub use runtime::ExecuteRequest; +pub use runtime::RuntimeResponse; +pub use runtime::WaitRequest; +pub use service::CodeModeService; +pub use service::CodeModeTurnHost; +pub use service::CodeModeTurnWorker; + +pub const PUBLIC_TOOL_NAME: &str = "exec"; +pub const WAIT_TOOL_NAME: &str = "wait"; diff --git a/codex-rs/code-mode/src/response.rs b/codex-rs/code-mode/src/response.rs new file mode 100644 index 0000000000..43579fac85 --- /dev/null +++ b/codex-rs/code-mode/src/response.rs @@ -0,0 +1,24 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageDetail { + Auto, + Low, + High, + Original, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum FunctionCallOutputContentItem { + InputText { + text: String, + }, + InputImage { + image_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, +} diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode/src/runtime/callbacks.rs new file mode 100644 index 0000000000..b77ae82d68 --- /dev/null +++ b/codex-rs/code-mode/src/runtime/callbacks.rs @@ -0,0 +1,209 @@ +use crate::response::FunctionCallOutputContentItem; + +use super::EXIT_SENTINEL; +use super::RuntimeEvent; +use super::RuntimeState; +use super::value::json_to_v8; +use super::value::normalize_output_image; +use super::value::serialize_output_text; +use super::value::throw_type_error; +use super::value::v8_value_to_json; + +pub(super) fn tool_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let tool_name = args.data().to_rust_string_lossy(scope); + let input = if args.length() == 0 { + Ok(None) + } else { + v8_value_to_json(scope, args.get(0)) + }; + let input = match input { + Ok(input) => input, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + + let Some(resolver) = v8::PromiseResolver::new(scope) else { + throw_type_error(scope, "failed to create tool promise"); + return; + }; + let promise = resolver.get_promise(scope); + + let resolver = v8::Global::new(scope, resolver); + let Some(state) = scope.get_slot_mut::() else { + throw_type_error(scope, "runtime state unavailable"); + return; + }; + let id = format!("tool-{}", state.next_tool_call_id); + state.next_tool_call_id = state.next_tool_call_id.saturating_add(1); + let event_tx = state.event_tx.clone(); + state.pending_tool_calls.insert(id.clone(), resolver); + let _ = event_tx.send(RuntimeEvent::ToolCall { + id, + name: tool_name, + input, + }); + retval.set(promise.into()); +} + +pub(super) fn text_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let text = match serialize_output_text(scope, value) { + Ok(text) => text, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { text }, + )); + } + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn image_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let image_item = match normalize_output_image(scope, value) { + Ok(image_item) => image_item, + Err(()) => return, + }; + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item)); + } + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn store_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + let key = match args.get(0).to_string(scope) { + Some(key) => key.to_rust_string_lossy(scope), + None => { + throw_type_error(scope, "store key must be a string"); + return; + } + }; + let value = args.get(1); + let serialized = match v8_value_to_json(scope, value) { + Ok(Some(value)) => value, + Ok(None) => { + throw_type_error( + scope, + &format!("Unable to store {key:?}. Only plain serializable objects can be stored."), + ); + return; + } + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + if let Some(state) = scope.get_slot_mut::() { + state.stored_values.insert(key, serialized); + } +} + +pub(super) fn load_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let key = match args.get(0).to_string(scope) { + Some(key) => key.to_rust_string_lossy(scope), + None => { + throw_type_error(scope, "load key must be a string"); + return; + } + }; + let value = scope + .get_slot::() + .and_then(|state| state.stored_values.get(&key)) + .cloned(); + let Some(value) = value else { + retval.set(v8::undefined(scope).into()); + return; + }; + let Some(value) = json_to_v8(scope, &value) else { + throw_type_error(scope, "failed to load stored value"); + return; + }; + retval.set(value); +} + +pub(super) fn notify_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let text = match serialize_output_text(scope, value) { + Ok(text) => text, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + if text.trim().is_empty() { + throw_type_error(scope, "notify expects non-empty text"); + return; + } + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::Notify { + call_id: state.tool_call_id.clone(), + text, + }); + } + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn yield_control_callback( + scope: &mut v8::PinScope<'_, '_>, + _args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::YieldRequested); + } +} + +pub(super) fn exit_callback( + scope: &mut v8::PinScope<'_, '_>, + _args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + if let Some(state) = scope.get_slot_mut::() { + state.exit_requested = true; + } + if let Some(error) = v8::String::new(scope, EXIT_SENTINEL) { + scope.throw_exception(error.into()); + } +} diff --git a/codex-rs/code-mode/src/runtime/globals.rs b/codex-rs/code-mode/src/runtime/globals.rs new file mode 100644 index 0000000000..371479497b --- /dev/null +++ b/codex-rs/code-mode/src/runtime/globals.rs @@ -0,0 +1,138 @@ +use super::RuntimeState; +use super::callbacks::exit_callback; +use super::callbacks::image_callback; +use super::callbacks::load_callback; +use super::callbacks::notify_callback; +use super::callbacks::store_callback; +use super::callbacks::text_callback; +use super::callbacks::tool_callback; +use super::callbacks::yield_control_callback; + +pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), String> { + let global = scope.get_current_context().global(scope); + let console = v8::String::new(scope, "console") + .ok_or_else(|| "failed to allocate global `console`".to_string())?; + if global.delete(scope, console.into()) != Some(true) { + return Err("failed to remove global `console`".to_string()); + } + + let tools = build_tools_object(scope)?; + let all_tools = build_all_tools_value(scope)?; + let text = helper_function(scope, "text", text_callback)?; + let image = helper_function(scope, "image", image_callback)?; + let store = helper_function(scope, "store", store_callback)?; + let load = helper_function(scope, "load", load_callback)?; + let notify = helper_function(scope, "notify", notify_callback)?; + let yield_control = helper_function(scope, "yield_control", yield_control_callback)?; + let exit = helper_function(scope, "exit", exit_callback)?; + + set_global(scope, global, "tools", tools.into())?; + set_global(scope, global, "ALL_TOOLS", all_tools)?; + set_global(scope, global, "text", text.into())?; + set_global(scope, global, "image", image.into())?; + set_global(scope, global, "store", store.into())?; + set_global(scope, global, "load", load.into())?; + set_global(scope, global, "notify", notify.into())?; + set_global(scope, global, "yield_control", yield_control.into())?; + set_global(scope, global, "exit", exit.into())?; + Ok(()) +} + +fn build_tools_object<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Result, String> { + let tools = v8::Object::new(scope); + let enabled_tools = scope + .get_slot::() + .map(|state| state.enabled_tools.clone()) + .unwrap_or_default(); + + for tool in enabled_tools { + let name = v8::String::new(scope, &tool.global_name) + .ok_or_else(|| "failed to allocate tool name".to_string())?; + let function = tool_function(scope, &tool.tool_name)?; + tools.set(scope, name.into(), function.into()); + } + Ok(tools) +} + +fn build_all_tools_value<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Result, String> { + let enabled_tools = scope + .get_slot::() + .map(|state| state.enabled_tools.clone()) + .unwrap_or_default(); + let array = v8::Array::new(scope, enabled_tools.len() as i32); + let name_key = v8::String::new(scope, "name") + .ok_or_else(|| "failed to allocate ALL_TOOLS name key".to_string())?; + let description_key = v8::String::new(scope, "description") + .ok_or_else(|| "failed to allocate ALL_TOOLS description key".to_string())?; + + for (index, tool) in enabled_tools.iter().enumerate() { + let item = v8::Object::new(scope); + let name = v8::String::new(scope, &tool.global_name) + .ok_or_else(|| "failed to allocate ALL_TOOLS name".to_string())?; + let description = v8::String::new(scope, &tool.description) + .ok_or_else(|| "failed to allocate ALL_TOOLS description".to_string())?; + + if item.set(scope, name_key.into(), name.into()) != Some(true) { + return Err("failed to set ALL_TOOLS name".to_string()); + } + if item.set(scope, description_key.into(), description.into()) != Some(true) { + return Err("failed to set ALL_TOOLS description".to_string()); + } + if array.set_index(scope, index as u32, item.into()) != Some(true) { + return Err("failed to append ALL_TOOLS metadata".to_string()); + } + } + + Ok(array.into()) +} + +fn helper_function<'s, F>( + scope: &mut v8::PinScope<'s, '_>, + name: &str, + callback: F, +) -> Result, String> +where + F: v8::MapFnTo, +{ + let name = + v8::String::new(scope, name).ok_or_else(|| "failed to allocate helper name".to_string())?; + let template = v8::FunctionTemplate::builder(callback) + .data(name.into()) + .build(scope); + template + .get_function(scope) + .ok_or_else(|| "failed to create helper function".to_string()) +} + +fn tool_function<'s>( + scope: &mut v8::PinScope<'s, '_>, + tool_name: &str, +) -> Result, String> { + let data = v8::String::new(scope, tool_name) + .ok_or_else(|| "failed to allocate tool callback data".to_string())?; + let template = v8::FunctionTemplate::builder(tool_callback) + .data(data.into()) + .build(scope); + template + .get_function(scope) + .ok_or_else(|| "failed to create tool function".to_string()) +} + +fn set_global<'s>( + scope: &mut v8::PinScope<'s, '_>, + global: v8::Local<'s, v8::Object>, + name: &str, + value: v8::Local<'s, v8::Value>, +) -> Result<(), String> { + let key = v8::String::new(scope, name) + .ok_or_else(|| format!("failed to allocate global `{name}`"))?; + if global.set(scope, key.into(), value) == Some(true) { + Ok(()) + } else { + Err(format!("failed to set global `{name}`")) + } +} diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs new file mode 100644 index 0000000000..df90eda673 --- /dev/null +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -0,0 +1,349 @@ +mod callbacks; +mod globals; +mod module_loader; +mod value; + +use std::collections::HashMap; +use std::sync::OnceLock; +use std::sync::mpsc as std_mpsc; +use std::thread; + +use serde_json::Value as JsonValue; +use tokio::sync::mpsc; + +use crate::description::EnabledToolMetadata; +use crate::description::ToolDefinition; +use crate::description::enabled_tool_metadata; +use crate::response::FunctionCallOutputContentItem; + +pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000; +pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000; +pub const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL: usize = 10_000; +const EXIT_SENTINEL: &str = "__codex_code_mode_exit__"; + +#[derive(Clone, Debug)] +pub struct ExecuteRequest { + pub tool_call_id: String, + pub enabled_tools: Vec, + pub source: String, + pub stored_values: HashMap, + pub yield_time_ms: Option, + pub max_output_tokens: Option, +} + +#[derive(Clone, Debug)] +pub struct WaitRequest { + pub cell_id: String, + pub yield_time_ms: u64, + pub terminate: bool, +} + +#[derive(Debug, PartialEq)] +pub enum RuntimeResponse { + Yielded { + cell_id: String, + content_items: Vec, + }, + Terminated { + cell_id: String, + content_items: Vec, + }, + Result { + cell_id: String, + content_items: Vec, + stored_values: HashMap, + error_text: Option, + }, +} + +#[derive(Debug)] +pub(crate) enum TurnMessage { + ToolCall { + cell_id: String, + id: String, + name: String, + input: Option, + }, + Notify { + cell_id: String, + call_id: String, + text: String, + }, +} + +#[derive(Debug)] +pub(crate) enum RuntimeCommand { + ToolResponse { id: String, result: JsonValue }, + ToolError { id: String, error_text: String }, + Terminate, +} + +#[derive(Debug)] +pub(crate) enum RuntimeEvent { + Started, + ContentItem(FunctionCallOutputContentItem), + YieldRequested, + ToolCall { + id: String, + name: String, + input: Option, + }, + Notify { + call_id: String, + text: String, + }, + Result { + stored_values: HashMap, + error_text: Option, + }, +} + +pub(crate) fn spawn_runtime( + request: ExecuteRequest, + event_tx: mpsc::UnboundedSender, +) -> Result<(std_mpsc::Sender, v8::IsolateHandle), String> { + let (command_tx, command_rx) = std_mpsc::channel(); + let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1); + let enabled_tools = request + .enabled_tools + .iter() + .map(enabled_tool_metadata) + .collect::>(); + let config = RuntimeConfig { + tool_call_id: request.tool_call_id, + enabled_tools, + source: request.source, + stored_values: request.stored_values, + }; + + thread::spawn(move || { + run_runtime(config, event_tx, command_rx, isolate_handle_tx); + }); + + let isolate_handle = isolate_handle_rx + .recv() + .map_err(|_| "failed to initialize code mode runtime".to_string())?; + Ok((command_tx, isolate_handle)) +} + +#[derive(Clone)] +struct RuntimeConfig { + tool_call_id: String, + enabled_tools: Vec, + source: String, + stored_values: HashMap, +} + +pub(super) struct RuntimeState { + event_tx: mpsc::UnboundedSender, + pending_tool_calls: HashMap>, + stored_values: HashMap, + enabled_tools: Vec, + next_tool_call_id: u64, + tool_call_id: String, + exit_requested: bool, +} + +pub(super) enum CompletionState { + Pending, + Completed { + stored_values: HashMap, + error_text: Option, + }, +} + +fn initialize_v8() { + static PLATFORM: OnceLock> = OnceLock::new(); + + let _ = PLATFORM.get_or_init(|| { + let platform = v8::new_default_platform(0, false).make_shared(); + v8::V8::initialize_platform(platform.clone()); + v8::V8::initialize(); + platform + }); +} + +fn run_runtime( + config: RuntimeConfig, + event_tx: mpsc::UnboundedSender, + command_rx: std_mpsc::Receiver, + isolate_handle_tx: std_mpsc::SyncSender, +) { + initialize_v8(); + + let isolate = &mut v8::Isolate::new(v8::CreateParams::default()); + let isolate_handle = isolate.thread_safe_handle(); + if isolate_handle_tx.send(isolate_handle).is_err() { + return; + } + isolate.set_host_import_module_dynamically_callback(module_loader::dynamic_import_callback); + + v8::scope!(let scope, isolate); + let context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, context); + + scope.set_slot(RuntimeState { + event_tx: event_tx.clone(), + pending_tool_calls: HashMap::new(), + stored_values: config.stored_values, + enabled_tools: config.enabled_tools, + next_tool_call_id: 1, + tool_call_id: config.tool_call_id, + exit_requested: false, + }); + + if let Err(error_text) = globals::install_globals(scope) { + send_result(&event_tx, HashMap::new(), Some(error_text)); + return; + } + + let _ = event_tx.send(RuntimeEvent::Started); + + let pending_promise = match module_loader::evaluate_main_module(scope, &config.source) { + Ok(pending_promise) => pending_promise, + Err(error_text) => { + capture_scope_send_error(scope, &event_tx, Some(error_text)); + return; + } + }; + + match module_loader::completion_state(scope, pending_promise.as_ref()) { + CompletionState::Completed { + stored_values, + error_text, + } => { + send_result(&event_tx, stored_values, error_text); + return; + } + CompletionState::Pending => {} + } + + let mut pending_promise = pending_promise; + loop { + let Ok(command) = command_rx.recv() else { + break; + }; + match command { + RuntimeCommand::Terminate => break, + RuntimeCommand::ToolResponse { id, result } => { + if let Err(error_text) = + module_loader::resolve_tool_response(scope, &id, Ok(result)) + { + capture_scope_send_error(scope, &event_tx, Some(error_text)); + return; + } + } + RuntimeCommand::ToolError { id, error_text } => { + if let Err(runtime_error) = + module_loader::resolve_tool_response(scope, &id, Err(error_text)) + { + capture_scope_send_error(scope, &event_tx, Some(runtime_error)); + return; + } + } + } + + scope.perform_microtask_checkpoint(); + match module_loader::completion_state(scope, pending_promise.as_ref()) { + CompletionState::Completed { + stored_values, + error_text, + } => { + send_result(&event_tx, stored_values, error_text); + return; + } + CompletionState::Pending => {} + } + + if let Some(promise) = pending_promise.as_ref() { + let promise = v8::Local::new(scope, promise); + if promise.state() != v8::PromiseState::Pending { + pending_promise = None; + } + } + } +} + +fn capture_scope_send_error( + scope: &mut v8::PinScope<'_, '_>, + event_tx: &mpsc::UnboundedSender, + error_text: Option, +) { + let stored_values = scope + .get_slot::() + .map(|state| state.stored_values.clone()) + .unwrap_or_default(); + + send_result(event_tx, stored_values, error_text); +} + +fn send_result( + event_tx: &mpsc::UnboundedSender, + stored_values: HashMap, + error_text: Option, +) { + let _ = event_tx.send(RuntimeEvent::Result { + stored_values, + error_text, + }); +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::time::Duration; + + use pretty_assertions::assert_eq; + use tokio::sync::mpsc; + + use super::ExecuteRequest; + use super::RuntimeEvent; + use super::spawn_runtime; + + fn execute_request(source: &str) -> ExecuteRequest { + ExecuteRequest { + tool_call_id: "call_1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + stored_values: HashMap::new(), + yield_time_ms: Some(1), + max_output_tokens: None, + } + } + + #[tokio::test] + async fn terminate_execution_stops_cpu_bound_module() { + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let (_runtime_tx, runtime_terminate_handle) = + spawn_runtime(execute_request("while (true) {}"), event_tx).unwrap(); + + let started_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!(started_event, RuntimeEvent::Started)); + + assert!(runtime_terminate_handle.terminate_execution()); + + let result_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + let RuntimeEvent::Result { + stored_values, + error_text, + } = result_event + else { + panic!("expected runtime result after termination"); + }; + assert_eq!(stored_values, HashMap::new()); + assert!(error_text.is_some()); + + assert!( + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .is_none() + ); + } +} diff --git a/codex-rs/code-mode/src/runtime/module_loader.rs b/codex-rs/code-mode/src/runtime/module_loader.rs new file mode 100644 index 0000000000..83ce3d347a --- /dev/null +++ b/codex-rs/code-mode/src/runtime/module_loader.rs @@ -0,0 +1,235 @@ +use serde_json::Value as JsonValue; + +use super::CompletionState; +use super::EXIT_SENTINEL; +use super::RuntimeState; +use super::value::json_to_v8; +use super::value::value_to_error_text; + +pub(super) fn evaluate_main_module( + scope: &mut v8::PinScope<'_, '_>, + source_text: &str, +) -> Result>, String> { + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let source = v8::String::new(&tc, source_text) + .ok_or_else(|| "failed to allocate exec source".to_string())?; + let origin = script_origin(&mut tc, "exec_main.mjs")?; + let mut source = v8::script_compiler::Source::new(source, Some(&origin)); + let module = v8::script_compiler::compile_module(&tc, &mut source).ok_or_else(|| { + tc.exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string()) + })?; + module + .instantiate_module(&tc, resolve_module_callback) + .ok_or_else(|| { + tc.exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string()) + })?; + let result = match module.evaluate(&tc) { + Some(result) => result, + None => { + if let Some(exception) = tc.exception() { + if is_exit_exception(&mut tc, exception) { + return Ok(None); + } + return Err(value_to_error_text(&mut tc, exception)); + } + return Err("unknown code mode exception".to_string()); + } + }; + tc.perform_microtask_checkpoint(); + + if result.is_promise() { + let promise = v8::Local::::try_from(result) + .map_err(|_| "failed to read exec promise".to_string())?; + return Ok(Some(v8::Global::new(&tc, promise))); + } + + Ok(None) +} + +fn is_exit_exception( + scope: &mut v8::PinScope<'_, '_>, + exception: v8::Local<'_, v8::Value>, +) -> bool { + scope + .get_slot::() + .map(|state| state.exit_requested) + .unwrap_or(false) + && exception.is_string() + && exception.to_rust_string_lossy(scope) == EXIT_SENTINEL +} + +pub(super) fn resolve_tool_response( + scope: &mut v8::PinScope<'_, '_>, + id: &str, + response: Result, +) -> Result<(), String> { + let resolver = { + let state = scope + .get_slot_mut::() + .ok_or_else(|| "runtime state unavailable".to_string())?; + state.pending_tool_calls.remove(id) + } + .ok_or_else(|| format!("unknown tool call `{id}`"))?; + + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let resolver = v8::Local::new(&tc, &resolver); + match response { + Ok(result) => { + let value = json_to_v8(&mut tc, &result) + .ok_or_else(|| "failed to serialize tool response".to_string())?; + resolver.resolve(&tc, value); + } + Err(error_text) => { + let value = v8::String::new(&tc, &error_text) + .ok_or_else(|| "failed to allocate tool error".to_string())?; + resolver.reject(&tc, value.into()); + } + } + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + Ok(()) +} + +pub(super) fn completion_state( + scope: &mut v8::PinScope<'_, '_>, + pending_promise: Option<&v8::Global>, +) -> CompletionState { + let stored_values = scope + .get_slot::() + .map(|state| state.stored_values.clone()) + .unwrap_or_default(); + + let Some(pending_promise) = pending_promise else { + return CompletionState::Completed { + stored_values, + error_text: None, + }; + }; + + let promise = v8::Local::new(scope, pending_promise); + match promise.state() { + v8::PromiseState::Pending => CompletionState::Pending, + v8::PromiseState::Fulfilled => CompletionState::Completed { + stored_values, + error_text: None, + }, + v8::PromiseState::Rejected => { + let result = promise.result(scope); + let error_text = if is_exit_exception(scope, result) { + None + } else { + Some(value_to_error_text(scope, result)) + }; + CompletionState::Completed { + stored_values, + error_text, + } + } + } +} + +fn script_origin<'s>( + scope: &mut v8::PinScope<'s, '_>, + resource_name_: &str, +) -> Result, String> { + let resource_name = v8::String::new(scope, resource_name_) + .ok_or_else(|| "failed to allocate script origin".to_string())?; + let source_map_url = v8::String::new(scope, resource_name_) + .ok_or_else(|| "failed to allocate source map url".to_string())?; + Ok(v8::ScriptOrigin::new( + scope, + resource_name.into(), + 0, + 0, + true, + 0, + Some(source_map_url.into()), + true, + false, + true, + None, + )) +} + +fn resolve_module_callback<'s>( + context: v8::Local<'s, v8::Context>, + specifier: v8::Local<'s, v8::String>, + _import_attributes: v8::Local<'s, v8::FixedArray>, + _referrer: v8::Local<'s, v8::Module>, +) -> Option> { + v8::callback_scope!(unsafe scope, context); + let specifier = specifier.to_rust_string_lossy(scope); + resolve_module(scope, &specifier) +} + +pub(super) fn dynamic_import_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + _host_defined_options: v8::Local<'s, v8::Data>, + _resource_name: v8::Local<'s, v8::Value>, + specifier: v8::Local<'s, v8::String>, + _import_attributes: v8::Local<'s, v8::FixedArray>, +) -> Option> { + let specifier = specifier.to_rust_string_lossy(scope); + let resolver = v8::PromiseResolver::new(scope)?; + + match resolve_module(scope, &specifier) { + Some(module) => { + if module.get_status() == v8::ModuleStatus::Uninstantiated + && module + .instantiate_module(scope, resolve_module_callback) + .is_none() + { + let error = v8::String::new(scope, "failed to instantiate module") + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + resolver.reject(scope, error); + return Some(resolver.get_promise(scope)); + } + if matches!( + module.get_status(), + v8::ModuleStatus::Instantiated | v8::ModuleStatus::Evaluated + ) && module.evaluate(scope).is_none() + { + let error = v8::String::new(scope, "failed to evaluate module") + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + resolver.reject(scope, error); + return Some(resolver.get_promise(scope)); + } + let namespace = module.get_module_namespace(); + resolver.resolve(scope, namespace); + Some(resolver.get_promise(scope)) + } + None => { + let error = v8::String::new(scope, "unsupported import in exec") + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + resolver.reject(scope, error); + Some(resolver.get_promise(scope)) + } + } +} + +fn resolve_module<'s>( + scope: &mut v8::PinScope<'s, '_>, + specifier: &str, +) -> Option> { + if let Some(message) = + v8::String::new(scope, &format!("Unsupported import in exec: {specifier}")) + { + scope.throw_exception(message.into()); + } else { + scope.throw_exception(v8::undefined(scope).into()); + } + None +} diff --git a/codex-rs/code-mode/src/runtime/value.rs b/codex-rs/code-mode/src/runtime/value.rs new file mode 100644 index 0000000000..eb0280142c --- /dev/null +++ b/codex-rs/code-mode/src/runtime/value.rs @@ -0,0 +1,163 @@ +use serde_json::Value as JsonValue; + +use crate::response::FunctionCallOutputContentItem; +use crate::response::ImageDetail; + +pub(super) fn serialize_output_text( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result { + if value.is_undefined() + || value.is_null() + || value.is_boolean() + || value.is_number() + || value.is_big_int() + || value.is_string() + { + return Ok(value.to_rust_string_lossy(scope)); + } + + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + if let Some(stringified) = v8::json::stringify(&tc, value) { + return Ok(stringified.to_rust_string_lossy(&tc)); + } + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + Ok(value.to_rust_string_lossy(&tc)) +} + +pub(super) fn normalize_output_image( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result { + let result = (|| -> Result { + let (image_url, detail) = if value.is_string() { + (value.to_rust_string_lossy(scope), None) + } else if value.is_object() && !value.is_array() { + let object = v8::Local::::try_from(value).map_err(|_| { + "image expects a non-empty image URL string or an object with image_url and optional detail".to_string() + })?; + let image_url_key = v8::String::new(scope, "image_url") + .ok_or_else(|| "failed to allocate image helper keys".to_string())?; + let detail_key = v8::String::new(scope, "detail") + .ok_or_else(|| "failed to allocate image helper keys".to_string())?; + let image_url = object + .get(scope, image_url_key.into()) + .filter(|value| value.is_string()) + .map(|value| value.to_rust_string_lossy(scope)) + .ok_or_else(|| { + "image expects a non-empty image URL string or an object with image_url and optional detail" + .to_string() + })?; + let detail = match object.get(scope, detail_key.into()) { + Some(value) if value.is_string() => Some(value.to_rust_string_lossy(scope)), + Some(value) if value.is_null() || value.is_undefined() => None, + Some(_) => return Err("image detail must be a string when provided".to_string()), + None => None, + }; + (image_url, detail) + } else { + return Err( + "image expects a non-empty image URL string or an object with image_url and optional detail" + .to_string(), + ); + }; + + if image_url.is_empty() { + return Err( + "image expects a non-empty image URL string or an object with image_url and optional detail" + .to_string(), + ); + } + let lower = image_url.to_ascii_lowercase(); + if !(lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("data:")) + { + return Err("image expects an http(s) or data URL".to_string()); + } + + let detail = match detail { + Some(detail) => { + let normalized = detail.to_ascii_lowercase(); + Some(match normalized.as_str() { + "auto" => ImageDetail::Auto, + "low" => ImageDetail::Low, + "high" => ImageDetail::High, + "original" => ImageDetail::Original, + _ => { + return Err( + "image detail must be one of: auto, low, high, original".to_string() + ); + } + }) + } + None => None, + }; + + Ok(FunctionCallOutputContentItem::InputImage { image_url, detail }) + })(); + + match result { + Ok(item) => Ok(item), + Err(error_text) => { + throw_type_error(scope, &error_text); + Err(()) + } + } +} + +pub(super) fn v8_value_to_json( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result, String> { + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let Some(stringified) = v8::json::stringify(&tc, value) else { + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + return Ok(None); + }; + serde_json::from_str(&stringified.to_rust_string_lossy(&tc)) + .map(Some) + .map_err(|err| format!("failed to serialize JavaScript value: {err}")) +} + +pub(super) fn json_to_v8<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: &JsonValue, +) -> Option> { + let json = serde_json::to_string(value).ok()?; + let json = v8::String::new(scope, &json)?; + v8::json::parse(scope, json) +} + +pub(super) fn value_to_error_text( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> String { + if value.is_object() + && let Ok(object) = v8::Local::::try_from(value) + && let Some(key) = v8::String::new(scope, "stack") + && let Some(stack) = object.get(scope, key.into()) + && stack.is_string() + { + return stack.to_rust_string_lossy(scope); + } + value.to_rust_string_lossy(scope) +} + +pub(super) fn throw_type_error(scope: &mut v8::PinScope<'_, '_>, message: &str) { + if let Some(message) = v8::String::new(scope, message) { + scope.throw_exception(message.into()); + } +} diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs new file mode 100644 index 0000000000..260b891d36 --- /dev/null +++ b/codex-rs/code-mode/src/service.rs @@ -0,0 +1,673 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::Value as JsonValue; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +use crate::FunctionCallOutputContentItem; +use crate::runtime::DEFAULT_EXEC_YIELD_TIME_MS; +use crate::runtime::ExecuteRequest; +use crate::runtime::RuntimeCommand; +use crate::runtime::RuntimeEvent; +use crate::runtime::RuntimeResponse; +use crate::runtime::TurnMessage; +use crate::runtime::WaitRequest; +use crate::runtime::spawn_runtime; + +#[async_trait] +pub trait CodeModeTurnHost: Send + Sync { + async fn invoke_tool( + &self, + tool_name: String, + input: Option, + cancellation_token: CancellationToken, + ) -> Result; + + async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>; +} + +#[derive(Clone)] +struct SessionHandle { + control_tx: mpsc::UnboundedSender, + runtime_tx: std::sync::mpsc::Sender, +} + +struct Inner { + stored_values: Mutex>, + sessions: Mutex>, + turn_message_tx: mpsc::UnboundedSender, + turn_message_rx: Arc>>, + next_cell_id: AtomicU64, +} + +pub struct CodeModeService { + inner: Arc, +} + +impl CodeModeService { + pub fn new() -> Self { + let (turn_message_tx, turn_message_rx) = mpsc::unbounded_channel(); + + Self { + inner: Arc::new(Inner { + stored_values: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + turn_message_tx, + turn_message_rx: Arc::new(Mutex::new(turn_message_rx)), + next_cell_id: AtomicU64::new(1), + }), + } + } + + pub async fn stored_values(&self) -> HashMap { + self.inner.stored_values.lock().await.clone() + } + + pub async fn replace_stored_values(&self, values: HashMap) { + *self.inner.stored_values.lock().await = values; + } + + pub async fn execute(&self, request: ExecuteRequest) -> Result { + let cell_id = self + .inner + .next_cell_id + .fetch_add(1, Ordering::Relaxed) + .to_string(); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (runtime_tx, runtime_terminate_handle) = spawn_runtime(request.clone(), event_tx)?; + let (control_tx, control_rx) = mpsc::unbounded_channel(); + let (response_tx, response_rx) = oneshot::channel(); + + self.inner.sessions.lock().await.insert( + cell_id.clone(), + SessionHandle { + control_tx: control_tx.clone(), + runtime_tx: runtime_tx.clone(), + }, + ); + + tokio::spawn(run_session_control( + Arc::clone(&self.inner), + SessionControlContext { + cell_id: cell_id.clone(), + runtime_tx, + runtime_terminate_handle, + }, + event_rx, + control_rx, + response_tx, + request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS), + )); + + response_rx + .await + .map_err(|_| "exec runtime ended unexpectedly".to_string()) + } + + pub async fn wait(&self, request: WaitRequest) -> Result { + let cell_id = request.cell_id.clone(); + let handle = self + .inner + .sessions + .lock() + .await + .get(&request.cell_id) + .cloned(); + let Some(handle) = handle else { + return Ok(missing_cell_response(cell_id)); + }; + let (response_tx, response_rx) = oneshot::channel(); + let control_message = if request.terminate { + SessionControlCommand::Terminate { response_tx } + } else { + SessionControlCommand::Poll { + yield_time_ms: request.yield_time_ms, + response_tx, + } + }; + if handle.control_tx.send(control_message).is_err() { + return Ok(missing_cell_response(cell_id)); + } + match response_rx.await { + Ok(response) => Ok(response), + Err(_) => Ok(missing_cell_response(request.cell_id)), + } + } + + pub fn start_turn_worker(&self, host: Arc) -> CodeModeTurnWorker { + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + let inner = Arc::clone(&self.inner); + let turn_message_rx = Arc::clone(&self.inner.turn_message_rx); + + tokio::spawn(async move { + loop { + let next_message = tokio::select! { + _ = &mut shutdown_rx => break, + message = async { + let mut turn_message_rx = turn_message_rx.lock().await; + turn_message_rx.recv().await + } => message, + }; + let Some(next_message) = next_message else { + break; + }; + match next_message { + TurnMessage::Notify { + cell_id, + call_id, + text, + } => { + if let Err(err) = host.notify(call_id, cell_id.clone(), text).await { + warn!( + "failed to deliver code mode notification for cell {cell_id}: {err}" + ); + } + } + TurnMessage::ToolCall { + cell_id, + id, + name, + input, + } => { + let host = Arc::clone(&host); + let inner = Arc::clone(&inner); + tokio::spawn(async move { + let response = host + .invoke_tool(name, input, CancellationToken::new()) + .await; + let runtime_tx = inner + .sessions + .lock() + .await + .get(&cell_id) + .map(|handle| handle.runtime_tx.clone()); + let Some(runtime_tx) = runtime_tx else { + return; + }; + let command = match response { + Ok(result) => RuntimeCommand::ToolResponse { id, result }, + Err(error_text) => RuntimeCommand::ToolError { id, error_text }, + }; + let _ = runtime_tx.send(command); + }); + } + } + } + }); + + CodeModeTurnWorker { + shutdown_tx: Some(shutdown_tx), + } + } +} + +impl Default for CodeModeService { + fn default() -> Self { + Self::new() + } +} + +pub struct CodeModeTurnWorker { + shutdown_tx: Option>, +} + +impl Drop for CodeModeTurnWorker { + fn drop(&mut self) { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + } +} + +enum SessionControlCommand { + Poll { + yield_time_ms: u64, + response_tx: oneshot::Sender, + }, + Terminate { + response_tx: oneshot::Sender, + }, +} + +struct PendingResult { + content_items: Vec, + stored_values: HashMap, + error_text: Option, +} + +struct SessionControlContext { + cell_id: String, + runtime_tx: std::sync::mpsc::Sender, + runtime_terminate_handle: v8::IsolateHandle, +} + +fn missing_cell_response(cell_id: String) -> RuntimeResponse { + RuntimeResponse::Result { + error_text: Some(format!("exec cell {cell_id} not found")), + cell_id, + content_items: Vec::new(), + stored_values: HashMap::new(), + } +} + +fn pending_result_response(cell_id: &str, result: PendingResult) -> RuntimeResponse { + RuntimeResponse::Result { + cell_id: cell_id.to_string(), + content_items: result.content_items, + stored_values: result.stored_values, + error_text: result.error_text, + } +} + +fn send_or_buffer_result( + cell_id: &str, + result: PendingResult, + response_tx: &mut Option>, + pending_result: &mut Option, +) -> bool { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(pending_result_response(cell_id, result)); + return true; + } + + *pending_result = Some(result); + false +} + +async fn run_session_control( + inner: Arc, + context: SessionControlContext, + mut event_rx: mpsc::UnboundedReceiver, + mut control_rx: mpsc::UnboundedReceiver, + initial_response_tx: oneshot::Sender, + initial_yield_time_ms: u64, +) { + let SessionControlContext { + cell_id, + runtime_tx, + runtime_terminate_handle, + } = context; + let mut content_items = Vec::new(); + let mut pending_result: Option = None; + let mut response_tx = Some(initial_response_tx); + let mut termination_requested = false; + let mut runtime_closed = false; + let mut yield_timer: Option>> = None; + + loop { + tokio::select! { + maybe_event = async { + if runtime_closed { + std::future::pending::>().await + } else { + event_rx.recv().await + } + } => { + let Some(event) = maybe_event else { + runtime_closed = true; + if termination_requested { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + break; + } + if pending_result.is_none() { + let result = PendingResult { + content_items: std::mem::take(&mut content_items), + stored_values: HashMap::new(), + error_text: Some("exec runtime ended unexpectedly".to_string()), + }; + if send_or_buffer_result( + &cell_id, + result, + &mut response_tx, + &mut pending_result, + ) { + break; + } + } + continue; + }; + match event { + RuntimeEvent::Started => { + yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms)))); + } + RuntimeEvent::ContentItem(item) => { + content_items.push(item); + } + RuntimeEvent::YieldRequested => { + yield_timer = None; + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Yielded { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + } + RuntimeEvent::Notify { call_id, text } => { + let _ = inner.turn_message_tx.send(TurnMessage::Notify { + cell_id: cell_id.clone(), + call_id, + text, + }); + } + RuntimeEvent::ToolCall { id, name, input } => { + let _ = inner.turn_message_tx.send(TurnMessage::ToolCall { + cell_id: cell_id.clone(), + id, + name, + input, + }); + } + RuntimeEvent::Result { + stored_values, + error_text, + } => { + yield_timer = None; + if termination_requested { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + break; + } + let result = PendingResult { + content_items: std::mem::take(&mut content_items), + stored_values, + error_text, + }; + if send_or_buffer_result( + &cell_id, + result, + &mut response_tx, + &mut pending_result, + ) { + break; + } + } + } + } + maybe_command = control_rx.recv() => { + let Some(command) = maybe_command else { + break; + }; + match command { + SessionControlCommand::Poll { + yield_time_ms, + response_tx: next_response_tx, + } => { + if let Some(result) = pending_result.take() { + let _ = next_response_tx.send(pending_result_response(&cell_id, result)); + break; + } + response_tx = Some(next_response_tx); + yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms)))); + } + SessionControlCommand::Terminate { response_tx: next_response_tx } => { + if let Some(result) = pending_result.take() { + let _ = next_response_tx.send(pending_result_response(&cell_id, result)); + break; + } + + response_tx = Some(next_response_tx); + termination_requested = true; + yield_timer = None; + let _ = runtime_tx.send(RuntimeCommand::Terminate); + let _ = runtime_terminate_handle.terminate_execution(); + if runtime_closed { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + break; + } else { + continue; + } + } + } + } + _ = async { + if let Some(yield_timer) = yield_timer.as_mut() { + yield_timer.await; + } else { + std::future::pending::<()>().await; + } + } => { + yield_timer = None; + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Yielded { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + } + } + } + + let _ = runtime_tx.send(RuntimeCommand::Terminate); + inner.sessions.lock().await.remove(&cell_id); +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + use std::sync::atomic::AtomicU64; + use std::time::Duration; + + use pretty_assertions::assert_eq; + use tokio::sync::Mutex; + use tokio::sync::mpsc; + use tokio::sync::oneshot; + + use super::CodeModeService; + use super::Inner; + use super::RuntimeCommand; + use super::RuntimeResponse; + use super::SessionControlCommand; + use super::SessionControlContext; + use super::run_session_control; + use crate::FunctionCallOutputContentItem; + use crate::runtime::ExecuteRequest; + use crate::runtime::RuntimeEvent; + use crate::runtime::spawn_runtime; + + fn execute_request(source: &str) -> ExecuteRequest { + ExecuteRequest { + tool_call_id: "call_1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + stored_values: HashMap::new(), + yield_time_ms: Some(1), + max_output_tokens: None, + } + } + + fn test_inner() -> Arc { + let (turn_message_tx, turn_message_rx) = mpsc::unbounded_channel(); + Arc::new(Inner { + stored_values: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + turn_message_tx, + turn_message_rx: Arc::new(Mutex::new(turn_message_rx)), + next_cell_id: AtomicU64::new(1), + }) + } + + #[tokio::test] + async fn synchronous_exit_returns_successfully() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#"text("before"); exit(); text("after");"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "before".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn v8_console_is_not_exposed_on_global_this() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#"text(String(Object.hasOwn(globalThis, "console")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "false".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn output_helpers_return_undefined() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +const returnsUndefined = [ + text("first"), + image("https://example.com/image.jpg"), + notify("ping"), +].map((value) => value === undefined); +text(JSON.stringify(returnsUndefined)); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![ + FunctionCallOutputContentItem::InputText { + text: "first".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/image.jpg".to_string(), + detail: None, + }, + FunctionCallOutputContentItem::InputText { + text: "[true,true,true]".to_string(), + }, + ], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn terminate_waits_for_runtime_shutdown_before_responding() { + let inner = test_inner(); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (control_tx, control_rx) = mpsc::unbounded_channel(); + let (initial_response_tx, initial_response_rx) = oneshot::channel(); + let (runtime_event_tx, _runtime_event_rx) = mpsc::unbounded_channel(); + let (runtime_tx, runtime_terminate_handle) = spawn_runtime( + ExecuteRequest { + source: "await new Promise(() => {})".to_string(), + yield_time_ms: None, + ..execute_request("") + }, + runtime_event_tx, + ) + .unwrap(); + + tokio::spawn(run_session_control( + inner, + SessionControlContext { + cell_id: "cell-1".to_string(), + runtime_tx: runtime_tx.clone(), + runtime_terminate_handle, + }, + event_rx, + control_rx, + initial_response_tx, + 60_000, + )); + + event_tx.send(RuntimeEvent::Started).unwrap(); + event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert_eq!( + initial_response_rx.await.unwrap(), + RuntimeResponse::Yielded { + cell_id: "cell-1".to_string(), + content_items: Vec::new(), + } + ); + + let (terminate_response_tx, terminate_response_rx) = oneshot::channel(); + control_tx + .send(SessionControlCommand::Terminate { + response_tx: terminate_response_tx, + }) + .unwrap(); + let terminate_response = async { terminate_response_rx.await.unwrap() }; + tokio::pin!(terminate_response); + assert!( + tokio::time::timeout(Duration::from_millis(100), terminate_response.as_mut()) + .await + .is_err() + ); + + drop(event_tx); + + assert_eq!( + terminate_response.await, + RuntimeResponse::Terminated { + cell_id: "cell-1".to_string(), + content_items: Vec::new(), + } + ); + + let _ = runtime_tx.send(RuntimeCommand::Terminate); + } +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d648655b24..6386c8d424 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -31,6 +31,7 @@ codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } +codex-code-mode = { workspace = true } codex-connectors = { workspace = true } codex-config = { workspace = true } codex-exec-server = { workspace = true } diff --git a/codex-rs/core/src/tools/code_mode/bridge.js b/codex-rs/core/src/tools/code_mode/bridge.js deleted file mode 100644 index 0c61a9db19..0000000000 --- a/codex-rs/core/src/tools/code_mode/bridge.js +++ /dev/null @@ -1,51 +0,0 @@ -const __codexContentItems = Array.isArray(globalThis.__codexContentItems) - ? globalThis.__codexContentItems - : []; -const __codexRuntime = globalThis.__codexRuntime; - -delete globalThis.__codexRuntime; - -Object.defineProperty(globalThis, '__codexContentItems', { - value: __codexContentItems, - configurable: true, - enumerable: false, - writable: false, -}); - -(() => { - if (!__codexRuntime || typeof __codexRuntime !== 'object') { - throw new Error('code mode runtime is unavailable'); - } - - function defineGlobal(name, value) { - Object.defineProperty(globalThis, name, { - value, - configurable: true, - enumerable: true, - writable: false, - }); - } - - defineGlobal('ALL_TOOLS', __codexRuntime.ALL_TOOLS); - defineGlobal('exit', __codexRuntime.exit); - defineGlobal('image', __codexRuntime.image); - defineGlobal('load', __codexRuntime.load); - defineGlobal('notify', __codexRuntime.notify); - defineGlobal('store', __codexRuntime.store); - defineGlobal('text', __codexRuntime.text); - defineGlobal('tools', __codexRuntime.tools); - defineGlobal('yield_control', __codexRuntime.yield_control); - - defineGlobal( - 'console', - Object.freeze({ - log() {}, - info() {}, - warn() {}, - error() {}, - debug() {}, - }) - ); -})(); - -__CODE_MODE_USER_CODE_PLACEHOLDER__ diff --git a/codex-rs/core/src/tools/code_mode/description.md b/codex-rs/core/src/tools/code_mode/description.md deleted file mode 100644 index e0a124c65f..0000000000 --- a/codex-rs/core/src/tools/code_mode/description.md +++ /dev/null @@ -1,19 +0,0 @@ -## exec -- Runs raw JavaScript in an isolated context (no Node, no file system, or network access, no console). -- Send raw JavaScript source text, not JSON, quoted strings, or markdown code fences. -- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`. -- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running. -- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens. -- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. -- Tool methods take either string or object as parameter. -- They return either a structured value or a string based on the description above. - -- Global helpers: -- `exit()`: Immediately ends the current script successfully (like an early return from the top level). -- `text(value: string | number | boolean | undefined | null)`: Appends a text item and returns it. Non-string values are stringified with `JSON.stringify(...)` when possible. -- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null })`: Appends an image item and returns it. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. -- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. -- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. -- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. -- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries. -- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running. diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 9eba126dd1..3f77216c16 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -1,8 +1,5 @@ use async_trait::async_trait; -use serde::Deserialize; -use crate::codex::Session; -use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; @@ -10,180 +7,52 @@ use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use super::CODE_MODE_PRAGMA_PREFIX; -use super::CodeModeSessionProgress; use super::ExecContext; use super::PUBLIC_TOOL_NAME; use super::build_enabled_tools; -use super::handle_node_message; -use super::protocol::HostToNodeMessage; -use super::protocol::build_source; +use super::handle_runtime_response; pub struct CodeModeExecuteHandler; -const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; - -#[derive(Debug, Default, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct CodeModeExecPragma { - #[serde(default)] - yield_time_ms: Option, - #[serde(default)] - max_output_tokens: Option, -} - -#[derive(Debug, PartialEq, Eq)] -struct CodeModeExecArgs { - code: String, - yield_time_ms: Option, - max_output_tokens: Option, -} impl CodeModeExecuteHandler { async fn execute( &self, - session: std::sync::Arc, - turn: std::sync::Arc, + session: std::sync::Arc, + turn: std::sync::Arc, call_id: String, code: String, ) -> Result { - let args = parse_freeform_args(&code)?; + let args = + codex_code_mode::parse_exec_source(&code).map_err(FunctionCallError::RespondToModel)?; let exec = ExecContext { session, turn }; let enabled_tools = build_enabled_tools(&exec).await; - let service = &exec.session.services.code_mode_service; - let stored_values = service.stored_values().await; - let source = - build_source(&args.code, &enabled_tools).map_err(FunctionCallError::RespondToModel)?; - let cell_id = service.allocate_cell_id().await; - let request_id = service.allocate_request_id().await; - let process_slot = service - .ensure_started() - .await - .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; + let stored_values = exec + .session + .services + .code_mode_service + .stored_values() + .await; let started_at = std::time::Instant::now(); - let message = HostToNodeMessage::Start { - request_id: request_id.clone(), - cell_id: cell_id.clone(), - tool_call_id: call_id, - default_yield_time_ms: super::DEFAULT_EXEC_YIELD_TIME_MS, - enabled_tools, - stored_values, - source, - yield_time_ms: args.yield_time_ms, - max_output_tokens: args.max_output_tokens, - }; - let result = { - let mut process_slot = process_slot; - let Some(process) = process_slot.as_mut() else { - return Err(FunctionCallError::RespondToModel(format!( - "{PUBLIC_TOOL_NAME} runner failed to start" - ))); - }; - let message = process - .send(&request_id, &message) - .await - .map_err(|err| err.to_string()); - let message = match message { - Ok(message) => message, - Err(error) => return Err(FunctionCallError::RespondToModel(error)), - }; - handle_node_message( - &exec, cell_id, message, /*poll_max_output_tokens*/ None, started_at, - ) + let response = exec + .session + .services + .code_mode_service + .execute(codex_code_mode::ExecuteRequest { + tool_call_id: call_id, + enabled_tools, + source: args.code, + stored_values, + yield_time_ms: args.yield_time_ms, + max_output_tokens: args.max_output_tokens, + }) .await - }; - match result { - Ok(CodeModeSessionProgress::Finished(output)) - | Ok(CodeModeSessionProgress::Yielded { output }) => Ok(output), - Err(error) => Err(FunctionCallError::RespondToModel(error)), - } + .map_err(FunctionCallError::RespondToModel)?; + handle_runtime_response(&exec, response, args.max_output_tokens, started_at) + .await + .map_err(FunctionCallError::RespondToModel) } } -fn parse_freeform_args(input: &str) -> Result { - if input.trim().is_empty() { - return Err(FunctionCallError::RespondToModel( - "exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(), - )); - } - - let mut args = CodeModeExecArgs { - code: input.to_string(), - yield_time_ms: None, - max_output_tokens: None, - }; - - let mut lines = input.splitn(2, '\n'); - let first_line = lines.next().unwrap_or_default(); - let rest = lines.next().unwrap_or_default(); - let trimmed = first_line.trim_start(); - let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else { - return Ok(args); - }; - - if rest.trim().is_empty() { - return Err(FunctionCallError::RespondToModel( - "exec pragma must be followed by JavaScript source on subsequent lines".to_string(), - )); - } - - let directive = pragma.trim(); - if directive.is_empty() { - return Err(FunctionCallError::RespondToModel( - "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" - .to_string(), - )); - } - - let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| { - FunctionCallError::RespondToModel(format!( - "exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}" - )) - })?; - let object = value.as_object().ok_or_else(|| { - FunctionCallError::RespondToModel( - "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" - .to_string(), - ) - })?; - for key in object.keys() { - match key.as_str() { - "yield_time_ms" | "max_output_tokens" => {} - _ => { - return Err(FunctionCallError::RespondToModel(format!( - "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`" - ))); - } - } - } - - let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| { - FunctionCallError::RespondToModel(format!( - "exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}" - )) - })?; - if pragma - .yield_time_ms - .is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER) - { - return Err(FunctionCallError::RespondToModel( - "exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(), - )); - } - if pragma.max_output_tokens.is_some_and(|max_output_tokens| { - u64::try_from(max_output_tokens) - .map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER) - .unwrap_or(true) - }) { - return Err(FunctionCallError::RespondToModel( - "exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(), - )); - } - args.code = rest.to_string(); - args.yield_time_ms = pragma.yield_time_ms; - args.max_output_tokens = pragma.max_output_tokens; - Ok(args) -} - #[async_trait] impl ToolHandler for CodeModeExecuteHandler { type Output = FunctionToolOutput; @@ -216,7 +85,3 @@ impl ToolHandler for CodeModeExecuteHandler { } } } - -#[cfg(test)] -#[path = "execute_handler_tests.rs"] -mod execute_handler_tests; diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index c8e1e0c165..a4838d2463 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -1,15 +1,18 @@ mod execute_handler; -mod process; -mod protocol; -mod service; +mod response_adapter; mod wait_handler; -mod worker; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use codex_code_mode::CodeModeTurnHost; +use codex_code_mode::RuntimeResponse; use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; use serde_json::Value as JsonValue; +use tokio_util::sync::CancellationToken; use crate::client_common::tools::ToolSpec; use crate::codex::Session; @@ -17,9 +20,8 @@ use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; use crate::tools::ToolRouter; use crate::tools::code_mode_description::augment_tool_spec_for_code_mode; -use crate::tools::code_mode_description::code_mode_tool_reference; -use crate::tools::code_mode_description::normalize_code_mode_identifier; use crate::tools::context::FunctionToolOutput; +use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolPayload; use crate::tools::parallel::ToolCallRuntime; use crate::tools::router::ToolCall; @@ -29,180 +31,202 @@ use crate::truncate::TruncationPolicy; use crate::truncate::formatted_truncate_text_content_items_with_policy; use crate::truncate::truncate_function_output_items_with_policy; use crate::unified_exec::resolve_max_tokens; +use codex_features::Feature; -const CODE_MODE_RUNNER_SOURCE: &str = include_str!("runner.cjs"); -const CODE_MODE_BRIDGE_SOURCE: &str = include_str!("bridge.js"); -const CODE_MODE_DESCRIPTION_TEMPLATE: &str = include_str!("description.md"); -const CODE_MODE_WAIT_DESCRIPTION_TEMPLATE: &str = include_str!("wait_description.md"); -const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:"; -const CODE_MODE_ONLY_PREFACE: &str = - "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; +pub(crate) use execute_handler::CodeModeExecuteHandler; +use response_adapter::into_function_call_output_content_items; +pub(crate) use wait_handler::CodeModeWaitHandler; -pub(crate) const PUBLIC_TOOL_NAME: &str = "exec"; -pub(crate) const WAIT_TOOL_NAME: &str = "wait"; - -pub(crate) fn is_code_mode_nested_tool(tool_name: &str) -> bool { - tool_name != PUBLIC_TOOL_NAME && tool_name != WAIT_TOOL_NAME -} -pub(crate) const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000; -pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000; +pub(crate) const PUBLIC_TOOL_NAME: &str = codex_code_mode::PUBLIC_TOOL_NAME; +pub(crate) const WAIT_TOOL_NAME: &str = codex_code_mode::WAIT_TOOL_NAME; +pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = codex_code_mode::DEFAULT_WAIT_YIELD_TIME_MS; #[derive(Clone)] -pub(super) struct ExecContext { +pub(crate) struct ExecContext { pub(super) session: Arc, pub(super) turn: Arc, } -pub(crate) use execute_handler::CodeModeExecuteHandler; -pub(crate) use service::CodeModeService; -pub(crate) use wait_handler::CodeModeWaitHandler; - -enum CodeModeSessionProgress { - Finished(FunctionToolOutput), - Yielded { output: FunctionToolOutput }, +pub(crate) struct CodeModeService { + inner: codex_code_mode::CodeModeService, } -enum CodeModeExecutionStatus { - Completed, - Failed, - Running(String), - Terminated, -} - -pub(crate) fn tool_description(enabled_tools: &[(String, String)], code_mode_only: bool) -> String { - let description_template = CODE_MODE_DESCRIPTION_TEMPLATE.trim_end(); - if !code_mode_only { - return description_template.to_string(); +impl CodeModeService { + pub(crate) fn new(_js_repl_node_path: Option) -> Self { + Self { + inner: codex_code_mode::CodeModeService::new(), + } } - let mut sections = vec![ - CODE_MODE_ONLY_PREFACE.to_string(), - description_template.to_string(), - ]; + pub(crate) async fn stored_values(&self) -> std::collections::HashMap { + self.inner.stored_values().await + } - if !enabled_tools.is_empty() { - let nested_tool_reference = enabled_tools - .iter() - .map(|(name, nested_description)| { - let global_name = normalize_code_mode_identifier(name); - format!( - "### `{global_name}` (`{name}`)\n{}", - nested_description.trim() - ) + pub(crate) async fn replace_stored_values( + &self, + values: std::collections::HashMap, + ) { + self.inner.replace_stored_values(values).await; + } + + pub(crate) async fn execute( + &self, + request: codex_code_mode::ExecuteRequest, + ) -> Result { + self.inner.execute(request).await + } + + pub(crate) async fn wait( + &self, + request: codex_code_mode::WaitRequest, + ) -> Result { + self.inner.wait(request).await + } + + pub(crate) async fn start_turn_worker( + &self, + session: &Arc, + turn: &Arc, + router: Arc, + tracker: SharedTurnDiffTracker, + ) -> Option { + if !turn.features.enabled(Feature::CodeMode) { + return None; + } + + let exec = ExecContext { + session: Arc::clone(session), + turn: Arc::clone(turn), + }; + let tool_runtime = + ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker); + let host = Arc::new(CoreTurnHost { exec, tool_runtime }); + Some(self.inner.start_turn_worker(host)) + } +} + +struct CoreTurnHost { + exec: ExecContext, + tool_runtime: ToolCallRuntime, +} + +#[async_trait::async_trait] +impl CodeModeTurnHost for CoreTurnHost { + async fn invoke_tool( + &self, + tool_name: String, + input: Option, + cancellation_token: CancellationToken, + ) -> Result { + call_nested_tool( + self.exec.clone(), + self.tool_runtime.clone(), + tool_name, + input, + cancellation_token, + ) + .await + .map_err(|error| error.to_string()) + } + + async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String> { + if text.trim().is_empty() { + return Ok(()); + } + self.exec + .session + .inject_response_items(vec![ResponseInputItem::CustomToolCallOutput { + call_id, + name: Some(PUBLIC_TOOL_NAME.to_string()), + output: FunctionCallOutputPayload::from_text(text), + }]) + .await + .map_err(|_| { + format!("failed to inject exec notify message for cell {cell_id}: no active turn") }) - .collect::>() - .join("\n\n"); - sections.push(nested_tool_reference); } - - sections.join("\n\n") } -pub(crate) fn wait_tool_description() -> &'static str { - CODE_MODE_WAIT_DESCRIPTION_TEMPLATE -} - -async fn handle_node_message( +pub(super) async fn handle_runtime_response( exec: &ExecContext, - cell_id: String, - message: protocol::NodeToHostMessage, - poll_max_output_tokens: Option>, + response: RuntimeResponse, + max_output_tokens: Option, started_at: std::time::Instant, -) -> Result { - match message { - protocol::NodeToHostMessage::ToolCall { .. } => Err(protocol::unexpected_tool_call_error()), - protocol::NodeToHostMessage::Notify { .. } => Err(format!( - "unexpected {PUBLIC_TOOL_NAME} notify message in response path" - )), - protocol::NodeToHostMessage::Yielded { content_items, .. } => { - let mut delta_items = output_content_items_from_json_values(content_items)?; - delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten()); - prepend_script_status( - &mut delta_items, - CodeModeExecutionStatus::Running(cell_id), - started_at.elapsed(), - ); - Ok(CodeModeSessionProgress::Yielded { - output: FunctionToolOutput::from_content(delta_items, Some(true)), - }) +) -> Result { + let script_status = format_script_status(&response); + + match response { + RuntimeResponse::Yielded { content_items, .. } => { + let mut content_items = into_function_call_output_content_items(content_items); + content_items = truncate_code_mode_result(content_items, max_output_tokens); + prepend_script_status(&mut content_items, &script_status, started_at.elapsed()); + Ok(FunctionToolOutput::from_content(content_items, Some(true))) } - protocol::NodeToHostMessage::Terminated { content_items, .. } => { - let mut delta_items = output_content_items_from_json_values(content_items)?; - delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten()); - prepend_script_status( - &mut delta_items, - CodeModeExecutionStatus::Terminated, - started_at.elapsed(), - ); - Ok(CodeModeSessionProgress::Finished( - FunctionToolOutput::from_content(delta_items, Some(true)), - )) + RuntimeResponse::Terminated { content_items, .. } => { + let mut content_items = into_function_call_output_content_items(content_items); + content_items = truncate_code_mode_result(content_items, max_output_tokens); + prepend_script_status(&mut content_items, &script_status, started_at.elapsed()); + Ok(FunctionToolOutput::from_content(content_items, Some(true))) } - protocol::NodeToHostMessage::Result { + RuntimeResponse::Result { content_items, stored_values, error_text, - max_output_tokens_per_exec_call, .. } => { + let mut content_items = into_function_call_output_content_items(content_items); exec.session .services .code_mode_service .replace_stored_values(stored_values) .await; - let mut delta_items = output_content_items_from_json_values(content_items)?; let success = error_text.is_none(); if let Some(error_text) = error_text { - delta_items.push(FunctionCallOutputContentItem::InputText { + content_items.push(FunctionCallOutputContentItem::InputText { text: format!("Script error:\n{error_text}"), }); } - - let mut delta_items = truncate_code_mode_result( - delta_items, - poll_max_output_tokens.unwrap_or(max_output_tokens_per_exec_call), - ); - prepend_script_status( - &mut delta_items, - if success { - CodeModeExecutionStatus::Completed - } else { - CodeModeExecutionStatus::Failed - }, - started_at.elapsed(), - ); - Ok(CodeModeSessionProgress::Finished( - FunctionToolOutput::from_content(delta_items, Some(success)), + content_items = truncate_code_mode_result(content_items, max_output_tokens); + prepend_script_status(&mut content_items, &script_status, started_at.elapsed()); + Ok(FunctionToolOutput::from_content( + content_items, + Some(success), )) } } } +fn format_script_status(response: &RuntimeResponse) -> String { + match response { + RuntimeResponse::Yielded { cell_id, .. } => { + format!("Script running with cell ID {cell_id}") + } + RuntimeResponse::Terminated { .. } => "Script terminated".to_string(), + RuntimeResponse::Result { error_text, .. } => { + if error_text.is_none() { + "Script completed".to_string() + } else { + "Script failed".to_string() + } + } + } +} + fn prepend_script_status( content_items: &mut Vec, - status: CodeModeExecutionStatus, + status: &str, wall_time: Duration, ) { let wall_time_seconds = ((wall_time.as_secs_f32()) * 10.0).round() / 10.0; - let header = format!( - "{}\nWall time {wall_time_seconds:.1} seconds\nOutput:\n", - match status { - CodeModeExecutionStatus::Completed => "Script completed".to_string(), - CodeModeExecutionStatus::Failed => "Script failed".to_string(), - CodeModeExecutionStatus::Running(cell_id) => { - format!("Script running with cell ID {cell_id}") - } - CodeModeExecutionStatus::Terminated => "Script terminated".to_string(), - } - ); + let header = format!("{status}\nWall time {wall_time_seconds:.1} seconds\nOutput:\n"); content_items.insert(0, FunctionCallOutputContentItem::InputText { text: header }); } fn truncate_code_mode_result( items: Vec, - max_output_tokens_per_exec_call: Option, + max_output_tokens: Option, ) -> Vec { - let max_output_tokens = resolve_max_tokens(max_output_tokens_per_exec_call); + let max_output_tokens = resolve_max_tokens(max_output_tokens); let policy = TruncationPolicy::Tokens(max_output_tokens); if items .iter() @@ -216,21 +240,9 @@ fn truncate_code_mode_result( truncate_function_output_items_with_policy(&items, policy) } -fn output_content_items_from_json_values( - content_items: Vec, -) -> Result, String> { - content_items - .into_iter() - .enumerate() - .map(|(index, item)| { - serde_json::from_value(item).map_err(|err| { - format!("invalid {PUBLIC_TOOL_NAME} content item at index {index}: {err}") - }) - }) - .collect() -} - -async fn build_enabled_tools(exec: &ExecContext) -> Vec { +pub(super) async fn build_enabled_tools( + exec: &ExecContext, +) -> Vec { let router = build_nested_router(exec).await; let mut out = router .specs() @@ -238,39 +250,37 @@ async fn build_enabled_tools(exec: &ExecContext) -> Vec { .map(|spec| augment_tool_spec_for_code_mode(spec, /*code_mode_enabled*/ true)) .filter_map(enabled_tool_from_spec) .collect::>(); - out.sort_by(|left, right| left.tool_name.cmp(&right.tool_name)); - out.dedup_by(|left, right| left.tool_name == right.tool_name); + out.sort_by(|left, right| left.name.cmp(&right.name)); + out.dedup_by(|left, right| left.name == right.name); out } -fn enabled_tool_from_spec(spec: ToolSpec) -> Option { +fn enabled_tool_from_spec(spec: ToolSpec) -> Option { let tool_name = spec.name().to_string(); - if !is_code_mode_nested_tool(&tool_name) { + if !codex_code_mode::is_code_mode_nested_tool(&tool_name) { return None; } - let reference = code_mode_tool_reference(&tool_name); - let global_name = normalize_code_mode_identifier(&tool_name); - let (description, kind) = match spec { - ToolSpec::Function(tool) => (tool.description, protocol::CodeModeToolKind::Function), - ToolSpec::Freeform(tool) => (tool.description, protocol::CodeModeToolKind::Freeform), + match spec { + ToolSpec::Function(tool) => Some(codex_code_mode::ToolDefinition { + name: tool_name, + description: tool.description, + kind: codex_code_mode::CodeModeToolKind::Function, + input_schema: serde_json::to_value(&tool.parameters).ok(), + output_schema: tool.output_schema, + }), + ToolSpec::Freeform(tool) => Some(codex_code_mode::ToolDefinition { + name: tool_name, + description: tool.description, + kind: codex_code_mode::CodeModeToolKind::Freeform, + input_schema: None, + output_schema: None, + }), ToolSpec::LocalShell {} | ToolSpec::ImageGeneration { .. } | ToolSpec::ToolSearch { .. } - | ToolSpec::WebSearch { .. } => { - return None; - } - }; - - Some(protocol::EnabledTool { - tool_name, - global_name, - module_path: reference.module_path, - namespace: reference.namespace, - name: normalize_code_mode_identifier(&reference.tool_key), - description, - kind, - }) + | ToolSpec::WebSearch { .. } => None, + } } async fn build_nested_router(exec: &ExecContext) -> ToolRouter { @@ -303,7 +313,7 @@ async fn call_nested_tool( tool_runtime: ToolCallRuntime, tool_name: String, input: Option, - cancellation_token: tokio_util::sync::CancellationToken, + cancellation_token: CancellationToken, ) -> Result { if tool_name == PUBLIC_TOOL_NAME { return Err(FunctionCallError::RespondToModel(format!( @@ -340,18 +350,18 @@ async fn call_nested_tool( Ok(result.code_mode_result()) } -fn tool_kind_for_spec(spec: &ToolSpec) -> protocol::CodeModeToolKind { +fn tool_kind_for_spec(spec: &ToolSpec) -> codex_code_mode::CodeModeToolKind { if matches!(spec, ToolSpec::Freeform(_)) { - protocol::CodeModeToolKind::Freeform + codex_code_mode::CodeModeToolKind::Freeform } else { - protocol::CodeModeToolKind::Function + codex_code_mode::CodeModeToolKind::Function } } fn tool_kind_for_name( spec: Option, tool_name: &str, -) -> Result { +) -> Result { spec.as_ref() .map(tool_kind_for_spec) .ok_or_else(|| format!("tool `{tool_name}` is not enabled in {PUBLIC_TOOL_NAME}")) @@ -364,8 +374,12 @@ fn build_nested_tool_payload( ) -> Result { let actual_kind = tool_kind_for_name(spec, tool_name)?; match actual_kind { - protocol::CodeModeToolKind::Function => build_function_tool_payload(tool_name, input), - protocol::CodeModeToolKind::Freeform => build_freeform_tool_payload(tool_name, input), + codex_code_mode::CodeModeToolKind::Function => { + build_function_tool_payload(tool_name, input) + } + codex_code_mode::CodeModeToolKind::Freeform => { + build_freeform_tool_payload(tool_name, input) + } } } diff --git a/codex-rs/core/src/tools/code_mode/process.rs b/codex-rs/core/src/tools/code_mode/process.rs deleted file mode 100644 index 6dd6cde3ae..0000000000 --- a/codex-rs/core/src/tools/code_mode/process.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use tokio::io::AsyncBufReadExt; -use tokio::io::AsyncReadExt; -use tokio::io::AsyncWriteExt; -use tokio::io::BufReader; -use tokio::sync::Mutex; -use tokio::sync::mpsc; -use tokio::sync::oneshot; -use tokio::task::JoinHandle; -use tracing::warn; - -use super::CODE_MODE_RUNNER_SOURCE; -use super::PUBLIC_TOOL_NAME; -use super::protocol::HostToNodeMessage; -use super::protocol::NodeToHostMessage; -use super::protocol::message_request_id; - -pub(super) struct CodeModeProcess { - pub(super) child: tokio::process::Child, - pub(super) stdin: Arc>, - pub(super) stdout_task: JoinHandle<()>, - pub(super) response_waiters: Arc>>>, - pub(super) message_rx: Arc>>, -} - -impl CodeModeProcess { - pub(super) async fn send( - &mut self, - request_id: &str, - message: &HostToNodeMessage, - ) -> Result { - if self.stdout_task.is_finished() { - return Err(std::io::Error::other(format!( - "{PUBLIC_TOOL_NAME} runner is not available" - ))); - } - - let (tx, rx) = oneshot::channel(); - self.response_waiters - .lock() - .await - .insert(request_id.to_string(), tx); - if let Err(err) = write_message(&self.stdin, message).await { - self.response_waiters.lock().await.remove(request_id); - return Err(err); - } - - match rx.await { - Ok(message) => Ok(message), - Err(_) => Err(std::io::Error::other(format!( - "{PUBLIC_TOOL_NAME} runner is not available" - ))), - } - } - - pub(super) fn has_exited(&mut self) -> Result { - self.child - .try_wait() - .map(|status| status.is_some()) - .map_err(std::io::Error::other) - } -} - -pub(super) async fn spawn_code_mode_process( - node_path: &std::path::Path, -) -> Result { - let mut cmd = tokio::process::Command::new(node_path); - cmd.arg("--experimental-vm-modules"); - cmd.arg("--eval"); - cmd.arg(CODE_MODE_RUNNER_SOURCE); - cmd.stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - - let mut child = cmd.spawn().map_err(std::io::Error::other)?; - let stdout = child.stdout.take().ok_or_else(|| { - std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stdout")) - })?; - let stderr = child.stderr.take().ok_or_else(|| { - std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stderr")) - })?; - let stdin = child - .stdin - .take() - .ok_or_else(|| std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stdin")))?; - let stdin = Arc::new(Mutex::new(stdin)); - let response_waiters = Arc::new(Mutex::new(HashMap::< - String, - oneshot::Sender, - >::new())); - let (message_tx, message_rx) = mpsc::unbounded_channel(); - - tokio::spawn(async move { - let mut reader = BufReader::new(stderr); - let mut buf = Vec::new(); - match reader.read_to_end(&mut buf).await { - Ok(_) => { - let stderr = String::from_utf8_lossy(&buf).trim().to_string(); - if !stderr.is_empty() { - warn!("{PUBLIC_TOOL_NAME} runner stderr: {stderr}"); - } - } - Err(err) => { - warn!("failed to read {PUBLIC_TOOL_NAME} stderr: {err}"); - } - } - }); - let stdout_task = tokio::spawn({ - let response_waiters = Arc::clone(&response_waiters); - async move { - let mut stdout_lines = BufReader::new(stdout).lines(); - loop { - let line = match stdout_lines.next_line().await { - Ok(line) => line, - Err(err) => { - warn!("failed to read {PUBLIC_TOOL_NAME} stdout: {err}"); - break; - } - }; - let Some(line) = line else { - break; - }; - if line.trim().is_empty() { - continue; - } - let message: NodeToHostMessage = match serde_json::from_str(&line) { - Ok(message) => message, - Err(err) => { - warn!("failed to parse {PUBLIC_TOOL_NAME} stdout message: {err}"); - break; - } - }; - match message { - message @ (NodeToHostMessage::ToolCall { .. } - | NodeToHostMessage::Notify { .. }) => { - let _ = message_tx.send(message); - } - message => { - if let Some(request_id) = message_request_id(&message) - && let Some(waiter) = response_waiters.lock().await.remove(request_id) - { - let _ = waiter.send(message); - } - } - } - } - response_waiters.lock().await.clear(); - } - }); - - Ok(CodeModeProcess { - child, - stdin, - stdout_task, - response_waiters, - message_rx: Arc::new(Mutex::new(message_rx)), - }) -} - -pub(super) async fn write_message( - stdin: &Arc>, - message: &HostToNodeMessage, -) -> Result<(), std::io::Error> { - let line = serde_json::to_string(message).map_err(std::io::Error::other)?; - let mut stdin = stdin.lock().await; - stdin.write_all(line.as_bytes()).await?; - stdin.write_all(b"\n").await?; - stdin.flush().await?; - Ok(()) -} diff --git a/codex-rs/core/src/tools/code_mode/protocol.rs b/codex-rs/core/src/tools/code_mode/protocol.rs deleted file mode 100644 index 2e72e1229c..0000000000 --- a/codex-rs/core/src/tools/code_mode/protocol.rs +++ /dev/null @@ -1,169 +0,0 @@ -use std::collections::HashMap; - -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value as JsonValue; - -use super::CODE_MODE_BRIDGE_SOURCE; -use super::PUBLIC_TOOL_NAME; - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub(super) enum CodeModeToolKind { - Function, - Freeform, -} - -#[derive(Clone, Debug, Serialize)] -pub(super) struct EnabledTool { - pub(super) tool_name: String, - pub(super) global_name: String, - #[serde(rename = "module")] - pub(super) module_path: String, - pub(super) namespace: Vec, - pub(super) name: String, - pub(super) description: String, - pub(super) kind: CodeModeToolKind, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -pub(super) struct CodeModeToolCall { - pub(super) request_id: String, - pub(super) id: String, - pub(super) name: String, - #[serde(default)] - pub(super) input: Option, -} - -#[derive(Clone, Debug, Deserialize)] -pub(super) struct CodeModeNotify { - pub(super) cell_id: String, - pub(super) call_id: String, - pub(super) text: String, -} - -#[derive(Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub(super) enum HostToNodeMessage { - Start { - request_id: String, - cell_id: String, - tool_call_id: String, - default_yield_time_ms: u64, - enabled_tools: Vec, - stored_values: HashMap, - source: String, - yield_time_ms: Option, - max_output_tokens: Option, - }, - Poll { - request_id: String, - cell_id: String, - yield_time_ms: u64, - }, - Terminate { - request_id: String, - cell_id: String, - }, - Response { - request_id: String, - id: String, - code_mode_result: JsonValue, - #[serde(default)] - error_text: Option, - }, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub(super) enum NodeToHostMessage { - ToolCall { - #[serde(flatten)] - tool_call: CodeModeToolCall, - }, - Yielded { - request_id: String, - content_items: Vec, - }, - Terminated { - request_id: String, - content_items: Vec, - }, - Notify { - #[serde(flatten)] - notify: CodeModeNotify, - }, - Result { - request_id: String, - content_items: Vec, - stored_values: HashMap, - #[serde(default)] - error_text: Option, - #[serde(default)] - max_output_tokens_per_exec_call: Option, - }, -} - -pub(super) fn build_source( - user_code: &str, - enabled_tools: &[EnabledTool], -) -> Result { - let enabled_tools_json = serde_json::to_string(enabled_tools) - .map_err(|err| format!("failed to serialize enabled tools: {err}"))?; - Ok(CODE_MODE_BRIDGE_SOURCE - .replace( - "__CODE_MODE_ENABLED_TOOLS_PLACEHOLDER__", - &enabled_tools_json, - ) - .replace("__CODE_MODE_USER_CODE_PLACEHOLDER__", user_code)) -} - -pub(super) fn message_request_id(message: &NodeToHostMessage) -> Option<&str> { - match message { - NodeToHostMessage::ToolCall { .. } => None, - NodeToHostMessage::Yielded { request_id, .. } - | NodeToHostMessage::Terminated { request_id, .. } - | NodeToHostMessage::Result { request_id, .. } => Some(request_id), - NodeToHostMessage::Notify { .. } => None, - } -} - -pub(super) fn unexpected_tool_call_error() -> String { - format!("{PUBLIC_TOOL_NAME} received an unexpected tool call response") -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use super::CodeModeNotify; - use super::NodeToHostMessage; - use super::message_request_id; - - #[test] - fn message_request_id_absent_for_notify() { - let message = NodeToHostMessage::Notify { - notify: CodeModeNotify { - cell_id: "1".to_string(), - call_id: "call-1".to_string(), - text: "hello".to_string(), - }, - }; - - assert_eq!(None, message_request_id(&message)); - } - - #[test] - fn message_request_id_present_for_result() { - let message = NodeToHostMessage::Result { - request_id: "req-1".to_string(), - content_items: Vec::new(), - stored_values: HashMap::new(), - error_text: None, - max_output_tokens_per_exec_call: None, - }; - - assert_eq!(Some("req-1"), message_request_id(&message)); - } -} diff --git a/codex-rs/core/src/tools/code_mode/response_adapter.rs b/codex-rs/core/src/tools/code_mode/response_adapter.rs new file mode 100644 index 0000000000..b90448acf9 --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/response_adapter.rs @@ -0,0 +1,44 @@ +use codex_code_mode::ImageDetail as CodeModeImageDetail; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::ImageDetail; + +trait IntoProtocol { + fn into_protocol(self) -> T; +} + +pub(super) fn into_function_call_output_content_items( + items: Vec, +) -> Vec { + items.into_iter().map(IntoProtocol::into_protocol).collect() +} + +impl IntoProtocol for CodeModeImageDetail { + fn into_protocol(self) -> ImageDetail { + let value = self; + match value { + CodeModeImageDetail::Auto => ImageDetail::Auto, + CodeModeImageDetail::Low => ImageDetail::Low, + CodeModeImageDetail::High => ImageDetail::High, + CodeModeImageDetail::Original => ImageDetail::Original, + } + } +} + +impl IntoProtocol + for codex_code_mode::FunctionCallOutputContentItem +{ + fn into_protocol(self) -> FunctionCallOutputContentItem { + let value = self; + match value { + codex_code_mode::FunctionCallOutputContentItem::InputText { text } => { + FunctionCallOutputContentItem::InputText { text } + } + codex_code_mode::FunctionCallOutputContentItem::InputImage { image_url, detail } => { + FunctionCallOutputContentItem::InputImage { + image_url, + detail: detail.map(IntoProtocol::into_protocol), + } + } + } + } +} diff --git a/codex-rs/core/src/tools/code_mode/runner.cjs b/codex-rs/core/src/tools/code_mode/runner.cjs deleted file mode 100644 index 8b4b322eb3..0000000000 --- a/codex-rs/core/src/tools/code_mode/runner.cjs +++ /dev/null @@ -1,938 +0,0 @@ -'use strict'; - -const readline = require('node:readline'); -const { Worker } = require('node:worker_threads'); - -const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL = 10000; - -function normalizeMaxOutputTokensPerExecCall(value) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError('max_output_tokens_per_exec_call must be a non-negative safe integer'); - } - return value; -} - -function normalizeYieldTime(value) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError('yield_time must be a non-negative safe integer'); - } - return value; -} - -function formatErrorText(error) { - return String(error && error.stack ? error.stack : error); -} - -function cloneJsonValue(value) { - return JSON.parse(JSON.stringify(value)); -} - -function clearTimer(timer) { - if (timer !== null) { - clearTimeout(timer); - } - return null; -} - -function takeContentItems(session) { - const clonedContentItems = cloneJsonValue(session.content_items); - session.content_items.splice(0, session.content_items.length); - return Array.isArray(clonedContentItems) ? clonedContentItems : []; -} - -function codeModeWorkerMain() { - 'use strict'; - - const { parentPort, workerData } = require('node:worker_threads'); - const vm = require('node:vm'); - const { SourceTextModule, SyntheticModule } = vm; - - function formatErrorText(error) { - return String(error && error.stack ? error.stack : error); - } - - function cloneJsonValue(value) { - return JSON.parse(JSON.stringify(value)); - } - - class CodeModeExitSignal extends Error { - constructor() { - super('code mode exit'); - this.name = 'CodeModeExitSignal'; - } - } - - function isCodeModeExitSignal(error) { - return error instanceof CodeModeExitSignal; - } - - function createToolCaller() { - let nextId = 0; - const pending = new Map(); - - parentPort.on('message', (message) => { - if (message.type === 'tool_response') { - const entry = pending.get(message.id); - if (!entry) { - return; - } - pending.delete(message.id); - entry.resolve(message.result ?? ''); - return; - } - - if (message.type === 'tool_response_error') { - const entry = pending.get(message.id); - if (!entry) { - return; - } - pending.delete(message.id); - entry.reject(new Error(message.error_text ?? 'tool call failed')); - return; - } - }); - - return (name, input) => { - const id = 'msg-' + ++nextId; - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); - parentPort.postMessage({ - type: 'tool_call', - id, - name: String(name), - input, - }); - }); - }; - } - - function createContentItems() { - const contentItems = []; - const push = contentItems.push.bind(contentItems); - contentItems.push = (...items) => { - for (const item of items) { - parentPort.postMessage({ - type: 'content_item', - item: cloneJsonValue(item), - }); - } - return push(...items); - }; - parentPort.on('message', (message) => { - if (message.type === 'clear_content') { - contentItems.splice(0, contentItems.length); - } - }); - return contentItems; - } - - function createGlobalToolsNamespace(callTool, enabledTools) { - const tools = Object.create(null); - - for (const { tool_name, global_name } of enabledTools) { - Object.defineProperty(tools, global_name, { - value: async (args) => callTool(tool_name, args), - configurable: false, - enumerable: true, - writable: false, - }); - } - - return Object.freeze(tools); - } - - function createModuleToolsNamespace(callTool, enabledTools) { - const tools = Object.create(null); - - for (const { tool_name, global_name } of enabledTools) { - Object.defineProperty(tools, global_name, { - value: async (args) => callTool(tool_name, args), - configurable: false, - enumerable: true, - writable: false, - }); - } - - return Object.freeze(tools); - } - - function createAllToolsMetadata(enabledTools) { - return Object.freeze( - enabledTools.map(({ global_name, description }) => - Object.freeze({ - name: global_name, - description, - }) - ) - ); - } - - function createToolsModule(context, callTool, enabledTools) { - const tools = createModuleToolsNamespace(callTool, enabledTools); - const allTools = createAllToolsMetadata(enabledTools); - const exportNames = ['ALL_TOOLS']; - - for (const { global_name } of enabledTools) { - if (global_name !== 'ALL_TOOLS') { - exportNames.push(global_name); - } - } - - const uniqueExportNames = [...new Set(exportNames)]; - - return new SyntheticModule( - uniqueExportNames, - function initToolsModule() { - this.setExport('ALL_TOOLS', allTools); - for (const exportName of uniqueExportNames) { - if (exportName !== 'ALL_TOOLS') { - this.setExport(exportName, tools[exportName]); - } - } - }, - { context } - ); - } - - function ensureContentItems(context) { - if (!Array.isArray(context.__codexContentItems)) { - context.__codexContentItems = []; - } - return context.__codexContentItems; - } - - function serializeOutputText(value) { - if (typeof value === 'string') { - return value; - } - if ( - typeof value === 'undefined' || - value === null || - typeof value === 'boolean' || - typeof value === 'number' || - typeof value === 'bigint' - ) { - return String(value); - } - - const serialized = JSON.stringify(value); - if (typeof serialized === 'string') { - return serialized; - } - - return String(value); - } - - function normalizeOutputImage(value) { - let imageUrl; - let detail; - if (typeof value === 'string') { - imageUrl = value; - } else if ( - value && - typeof value === 'object' && - !Array.isArray(value) - ) { - if (typeof value.image_url === 'string') { - imageUrl = value.image_url; - } - if (typeof value.detail === 'string') { - detail = value.detail; - } else if ( - Object.prototype.hasOwnProperty.call(value, 'detail') && - value.detail !== null && - typeof value.detail !== 'undefined' - ) { - throw new TypeError('image detail must be a string when provided'); - } - } - - if (typeof imageUrl !== 'string' || !imageUrl) { - throw new TypeError( - 'image expects a non-empty image URL string or an object with image_url and optional detail' - ); - } - if (!/^(?:https?:\/\/|data:)/i.test(imageUrl)) { - throw new TypeError('image expects an http(s) or data URL'); - } - - if (typeof detail !== 'undefined' && !/^(?:auto|low|high|original)$/i.test(detail)) { - throw new TypeError('image detail must be one of: auto, low, high, original'); - } - - const normalized = { image_url: imageUrl }; - if (typeof detail === 'string') { - normalized.detail = detail.toLowerCase(); - } - return normalized; - } - - function createCodeModeHelpers(context, state, toolCallId) { - const load = (key) => { - if (typeof key !== 'string') { - throw new TypeError('load key must be a string'); - } - if (!Object.prototype.hasOwnProperty.call(state.storedValues, key)) { - return undefined; - } - return cloneJsonValue(state.storedValues[key]); - }; - const store = (key, value) => { - if (typeof key !== 'string') { - throw new TypeError('store key must be a string'); - } - state.storedValues[key] = cloneJsonValue(value); - }; - const text = (value) => { - const item = { - type: 'input_text', - text: serializeOutputText(value), - }; - ensureContentItems(context).push(item); - return item; - }; - const image = (value) => { - const item = Object.assign({ type: 'input_image' }, normalizeOutputImage(value)); - ensureContentItems(context).push(item); - return item; - }; - const yieldControl = () => { - parentPort.postMessage({ type: 'yield' }); - }; - const notify = (value) => { - const text = serializeOutputText(value); - if (text.trim().length === 0) { - throw new TypeError('notify expects non-empty text'); - } - if (typeof toolCallId !== 'string' || toolCallId.length === 0) { - throw new TypeError('notify requires a valid tool call id'); - } - parentPort.postMessage({ - type: 'notify', - call_id: toolCallId, - text, - }); - return text; - }; - const exit = () => { - throw new CodeModeExitSignal(); - }; - - return Object.freeze({ - exit, - image, - load, - notify, - output_image: image, - output_text: text, - store, - text, - yield_control: yieldControl, - }); - } - - function createCodeModeModule(context, helpers) { - return new SyntheticModule( - [ - 'exit', - 'image', - 'load', - 'notify', - 'output_text', - 'output_image', - 'store', - 'text', - 'yield_control', - ], - function initCodeModeModule() { - this.setExport('exit', helpers.exit); - this.setExport('image', helpers.image); - this.setExport('load', helpers.load); - this.setExport('notify', helpers.notify); - this.setExport('output_text', helpers.output_text); - this.setExport('output_image', helpers.output_image); - this.setExport('store', helpers.store); - this.setExport('text', helpers.text); - this.setExport('yield_control', helpers.yield_control); - }, - { context } - ); - } - - function createBridgeRuntime(callTool, enabledTools, helpers) { - return Object.freeze({ - ALL_TOOLS: createAllToolsMetadata(enabledTools), - exit: helpers.exit, - image: helpers.image, - load: helpers.load, - notify: helpers.notify, - store: helpers.store, - text: helpers.text, - tools: createGlobalToolsNamespace(callTool, enabledTools), - yield_control: helpers.yield_control, - }); - } - - function namespacesMatch(left, right) { - if (left.length !== right.length) { - return false; - } - return left.every((segment, index) => segment === right[index]); - } - - function createNamespacedToolsNamespace(callTool, enabledTools, namespace) { - const tools = Object.create(null); - - for (const tool of enabledTools) { - const toolNamespace = Array.isArray(tool.namespace) ? tool.namespace : []; - if (!namespacesMatch(toolNamespace, namespace)) { - continue; - } - - Object.defineProperty(tools, tool.name, { - value: async (args) => callTool(tool.tool_name, args), - configurable: false, - enumerable: true, - writable: false, - }); - } - - return Object.freeze(tools); - } - - function createNamespacedToolsModule(context, callTool, enabledTools, namespace) { - const tools = createNamespacedToolsNamespace(callTool, enabledTools, namespace); - const exportNames = []; - - for (const exportName of Object.keys(tools)) { - if (exportName !== 'ALL_TOOLS') { - exportNames.push(exportName); - } - } - - const uniqueExportNames = [...new Set(exportNames)]; - - return new SyntheticModule( - uniqueExportNames, - function initNamespacedToolsModule() { - for (const exportName of uniqueExportNames) { - this.setExport(exportName, tools[exportName]); - } - }, - { context } - ); - } - - function createModuleResolver(context, callTool, enabledTools, helpers) { - let toolsModule; - let codeModeModule; - const namespacedModules = new Map(); - - return function resolveModule(specifier) { - if (specifier === 'tools.js') { - toolsModule ??= createToolsModule(context, callTool, enabledTools); - return toolsModule; - } - if (specifier === '@openai/code_mode' || specifier === 'openai/code_mode') { - codeModeModule ??= createCodeModeModule(context, helpers); - return codeModeModule; - } - const namespacedMatch = /^tools\/(.+)\.js$/.exec(specifier); - if (!namespacedMatch) { - throw new Error('Unsupported import in exec: ' + specifier); - } - - const namespace = namespacedMatch[1] - .split('/') - .filter((segment) => segment.length > 0); - if (namespace.length === 0) { - throw new Error('Unsupported import in exec: ' + specifier); - } - - const cacheKey = namespace.join('/'); - if (!namespacedModules.has(cacheKey)) { - namespacedModules.set( - cacheKey, - createNamespacedToolsModule(context, callTool, enabledTools, namespace) - ); - } - return namespacedModules.get(cacheKey); - }; - } - - async function resolveDynamicModule(specifier, resolveModule) { - const module = resolveModule(specifier); - - if (module.status === 'unlinked') { - await module.link(resolveModule); - } - - if (module.status === 'linked' || module.status === 'evaluating') { - await module.evaluate(); - } - - if (module.status === 'errored') { - throw module.error; - } - - return module; - } - - async function runModule(context, start, callTool, helpers) { - const resolveModule = createModuleResolver( - context, - callTool, - start.enabled_tools ?? [], - helpers - ); - const mainModule = new SourceTextModule(start.source, { - context, - identifier: 'exec_main.mjs', - importModuleDynamically: async (specifier) => - resolveDynamicModule(specifier, resolveModule), - }); - - await mainModule.link(resolveModule); - await mainModule.evaluate(); - } - - async function main() { - const start = workerData ?? {}; - const toolCallId = start.tool_call_id; - const state = { - storedValues: cloneJsonValue(start.stored_values ?? {}), - }; - const callTool = createToolCaller(); - const enabledTools = start.enabled_tools ?? []; - const contentItems = createContentItems(); - const context = vm.createContext({ - __codexContentItems: contentItems, - }); - const helpers = createCodeModeHelpers(context, state, toolCallId); - Object.defineProperty(context, '__codexRuntime', { - value: createBridgeRuntime(callTool, enabledTools, helpers), - configurable: true, - enumerable: false, - writable: false, - }); - - parentPort.postMessage({ type: 'started' }); - try { - await runModule(context, start, callTool, helpers); - parentPort.postMessage({ - type: 'result', - stored_values: state.storedValues, - }); - } catch (error) { - if (isCodeModeExitSignal(error)) { - parentPort.postMessage({ - type: 'result', - stored_values: state.storedValues, - }); - return; - } - parentPort.postMessage({ - type: 'result', - stored_values: state.storedValues, - error_text: formatErrorText(error), - }); - } - } - - void main().catch((error) => { - parentPort.postMessage({ - type: 'result', - stored_values: {}, - error_text: formatErrorText(error), - }); - }); -} - -function createProtocol() { - const rl = readline.createInterface({ - input: process.stdin, - crlfDelay: Infinity, - }); - - let nextId = 0; - const pending = new Map(); - const sessions = new Map(); - let closedResolve; - const closed = new Promise((resolve) => { - closedResolve = resolve; - }); - - rl.on('line', (line) => { - if (!line.trim()) { - return; - } - - let message; - try { - message = JSON.parse(line); - } catch (error) { - process.stderr.write(formatErrorText(error) + '\n'); - return; - } - - if (message.type === 'start') { - startSession(protocol, sessions, message); - return; - } - - if (message.type === 'poll') { - const session = sessions.get(message.cell_id); - if (session) { - session.request_id = String(message.request_id); - if (session.pending_result) { - void completeSession(protocol, sessions, session, session.pending_result); - } else { - schedulePollYield(protocol, session, normalizeYieldTime(message.yield_time_ms ?? 0)); - } - } else { - void protocol.send({ - type: 'result', - request_id: message.request_id, - content_items: [], - stored_values: {}, - error_text: `exec cell ${message.cell_id} not found`, - max_output_tokens_per_exec_call: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, - }); - } - return; - } - - if (message.type === 'terminate') { - const session = sessions.get(message.cell_id); - if (session) { - session.request_id = String(message.request_id); - void terminateSession(protocol, sessions, session); - } else { - void protocol.send({ - type: 'result', - request_id: message.request_id, - content_items: [], - stored_values: {}, - error_text: `exec cell ${message.cell_id} not found`, - max_output_tokens_per_exec_call: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, - }); - } - return; - } - - if (message.type === 'response') { - const entry = pending.get(message.request_id + ':' + message.id); - if (!entry) { - return; - } - pending.delete(message.request_id + ':' + message.id); - if (typeof message.error_text === 'string') { - entry.reject(new Error(message.error_text)); - return; - } - entry.resolve(message.code_mode_result ?? ''); - return; - } - - process.stderr.write('Unknown protocol message type: ' + message.type + '\n'); - }); - - rl.on('close', () => { - const error = new Error('stdin closed'); - for (const entry of pending.values()) { - entry.reject(error); - } - pending.clear(); - for (const session of sessions.values()) { - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - void session.worker.terminate().catch(() => {}); - } - sessions.clear(); - closedResolve(); - }); - - function send(message) { - return new Promise((resolve, reject) => { - process.stdout.write(JSON.stringify(message) + '\n', (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); - } - - function request(type, payload) { - const requestId = 'req-' + ++nextId; - const id = 'msg-' + ++nextId; - const pendingKey = requestId + ':' + id; - return new Promise((resolve, reject) => { - pending.set(pendingKey, { resolve, reject }); - void send({ type, request_id: requestId, id, ...payload }).catch((error) => { - pending.delete(pendingKey); - reject(error); - }); - }); - } - - const protocol = { closed, request, send }; - return protocol; -} - -function sessionWorkerSource() { - return '(' + codeModeWorkerMain.toString() + ')();'; -} - -function startSession(protocol, sessions, start) { - if (typeof start.tool_call_id !== 'string' || start.tool_call_id.length === 0) { - throw new TypeError('start requires a valid tool_call_id'); - } - const maxOutputTokensPerExecCall = - start.max_output_tokens == null - ? DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL - : normalizeMaxOutputTokensPerExecCall(start.max_output_tokens); - const session = { - completed: false, - content_items: [], - default_yield_time_ms: normalizeYieldTime(start.default_yield_time_ms), - id: start.cell_id, - initial_yield_time_ms: - start.yield_time_ms == null - ? normalizeYieldTime(start.default_yield_time_ms) - : normalizeYieldTime(start.yield_time_ms), - initial_yield_timer: null, - initial_yield_triggered: false, - max_output_tokens_per_exec_call: maxOutputTokensPerExecCall, - pending_result: null, - poll_yield_timer: null, - request_id: String(start.request_id), - worker: new Worker(sessionWorkerSource(), { - eval: true, - workerData: start, - }), - }; - sessions.set(session.id, session); - - session.worker.on('message', (message) => { - void handleWorkerMessage(protocol, sessions, session, message).catch((error) => { - void completeSession(protocol, sessions, session, { - type: 'result', - stored_values: {}, - error_text: formatErrorText(error), - }); - }); - }); - session.worker.on('error', (error) => { - void completeSession(protocol, sessions, session, { - type: 'result', - stored_values: {}, - error_text: formatErrorText(error), - }); - }); - session.worker.on('exit', (code) => { - if (code !== 0 && !session.completed) { - void completeSession(protocol, sessions, session, { - type: 'result', - stored_values: {}, - error_text: 'exec worker exited with code ' + code, - }); - } - }); -} - -async function handleWorkerMessage(protocol, sessions, session, message) { - if (session.completed) { - return; - } - - if (message.type === 'content_item') { - session.content_items.push(cloneJsonValue(message.item)); - return; - } - - if (message.type === 'started') { - scheduleInitialYield(protocol, session, session.initial_yield_time_ms); - return; - } - - if (message.type === 'yield') { - void sendYielded(protocol, session); - return; - } - - if (message.type === 'notify') { - if (typeof message.text !== 'string' || message.text.trim().length === 0) { - throw new TypeError('notify requires non-empty text'); - } - if (typeof message.call_id !== 'string' || message.call_id.length === 0) { - throw new TypeError('notify requires a valid call id'); - } - await protocol.send({ - type: 'notify', - cell_id: session.id, - call_id: message.call_id, - text: message.text, - }); - return; - } - - if (message.type === 'tool_call') { - void forwardToolCall(protocol, session, message); - return; - } - - if (message.type === 'result') { - const result = { - type: 'result', - stored_values: cloneJsonValue(message.stored_values ?? {}), - error_text: - typeof message.error_text === 'string' ? message.error_text : undefined, - }; - if (session.request_id === null) { - session.pending_result = result; - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - return; - } - await completeSession(protocol, sessions, session, result); - return; - } - - process.stderr.write('Unknown worker message type: ' + message.type + '\n'); -} - -async function forwardToolCall(protocol, session, message) { - try { - const result = await protocol.request('tool_call', { - name: String(message.name), - input: message.input, - }); - if (session.completed) { - return; - } - try { - session.worker.postMessage({ - type: 'tool_response', - id: message.id, - result, - }); - } catch {} - } catch (error) { - if (session.completed) { - return; - } - try { - session.worker.postMessage({ - type: 'tool_response_error', - id: message.id, - error_text: formatErrorText(error), - }); - } catch {} - } -} - -async function sendYielded(protocol, session) { - if (session.completed || session.request_id === null) { - return; - } - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.initial_yield_triggered = true; - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - const contentItems = takeContentItems(session); - const requestId = session.request_id; - try { - session.worker.postMessage({ type: 'clear_content' }); - } catch {} - await protocol.send({ - type: 'yielded', - request_id: requestId, - content_items: contentItems, - }); - session.request_id = null; -} - -function scheduleInitialYield(protocol, session, yieldTime) { - if (session.completed || session.initial_yield_triggered) { - return yieldTime; - } - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.initial_yield_timer = setTimeout(() => { - session.initial_yield_timer = null; - session.initial_yield_triggered = true; - void sendYielded(protocol, session); - }, yieldTime); - return yieldTime; -} - -function schedulePollYield(protocol, session, yieldTime) { - if (session.completed) { - return; - } - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - session.poll_yield_timer = setTimeout(() => { - session.poll_yield_timer = null; - void sendYielded(protocol, session); - }, yieldTime); -} - -async function completeSession(protocol, sessions, session, message) { - if (session.completed) { - return; - } - if (session.request_id === null) { - session.pending_result = message; - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - return; - } - const requestId = session.request_id; - session.completed = true; - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - sessions.delete(session.id); - const contentItems = takeContentItems(session); - session.pending_result = null; - try { - session.worker.postMessage({ type: 'clear_content' }); - } catch {} - await protocol.send({ - ...message, - request_id: requestId, - content_items: contentItems, - max_output_tokens_per_exec_call: session.max_output_tokens_per_exec_call, - }); -} - -async function terminateSession(protocol, sessions, session) { - if (session.completed) { - return; - } - session.completed = true; - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - sessions.delete(session.id); - const contentItems = takeContentItems(session); - try { - await session.worker.terminate(); - } catch {} - await protocol.send({ - type: 'terminated', - request_id: session.request_id, - content_items: contentItems, - }); -} - -async function main() { - const protocol = createProtocol(); - await protocol.closed; -} - -void main().catch(async (error) => { - try { - process.stderr.write(formatErrorText(error) + '\n'); - } finally { - process.exitCode = 1; - } -}); diff --git a/codex-rs/core/src/tools/code_mode/service.rs b/codex-rs/core/src/tools/code_mode/service.rs deleted file mode 100644 index a9fadedb82..0000000000 --- a/codex-rs/core/src/tools/code_mode/service.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; - -use serde_json::Value as JsonValue; -use tokio::sync::Mutex; -use tracing::warn; - -use crate::codex::Session; -use crate::codex::TurnContext; -use crate::tools::ToolRouter; -use crate::tools::context::SharedTurnDiffTracker; -use crate::tools::js_repl::resolve_compatible_node; -use crate::tools::parallel::ToolCallRuntime; -use codex_features::Feature; - -use super::ExecContext; -use super::PUBLIC_TOOL_NAME; -use super::process::CodeModeProcess; -use super::process::spawn_code_mode_process; -use super::worker::CodeModeWorker; - -pub(crate) struct CodeModeService { - js_repl_node_path: Option, - stored_values: Mutex>, - process: Arc>>, - next_cell_id: Mutex, -} - -impl CodeModeService { - pub(crate) fn new(js_repl_node_path: Option) -> Self { - Self { - js_repl_node_path, - stored_values: Mutex::new(HashMap::new()), - process: Arc::new(Mutex::new(None)), - next_cell_id: Mutex::new(1), - } - } - - pub(crate) async fn stored_values(&self) -> HashMap { - self.stored_values.lock().await.clone() - } - - pub(crate) async fn replace_stored_values(&self, values: HashMap) { - *self.stored_values.lock().await = values; - } - - pub(super) async fn ensure_started( - &self, - ) -> Result>, std::io::Error> { - let mut process_slot = self.process.lock().await; - let needs_spawn = match process_slot.as_mut() { - Some(process) => !matches!(process.has_exited(), Ok(false)), - None => true, - }; - if needs_spawn { - let node_path = resolve_compatible_node(self.js_repl_node_path.as_deref()) - .await - .map_err(std::io::Error::other)?; - *process_slot = Some(spawn_code_mode_process(&node_path).await?); - } - drop(process_slot); - Ok(self.process.clone().lock_owned().await) - } - - pub(crate) async fn start_turn_worker( - &self, - session: &Arc, - turn: &Arc, - router: Arc, - tracker: SharedTurnDiffTracker, - ) -> Option { - if !turn.features.enabled(Feature::CodeMode) { - return None; - } - let exec = ExecContext { - session: Arc::clone(session), - turn: Arc::clone(turn), - }; - let tool_runtime = - ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker); - let mut process_slot = match self.ensure_started().await { - Ok(process_slot) => process_slot, - Err(err) => { - warn!("failed to start {PUBLIC_TOOL_NAME} worker for turn: {err}"); - return None; - } - }; - let Some(process) = process_slot.as_mut() else { - warn!( - "failed to start {PUBLIC_TOOL_NAME} worker for turn: {PUBLIC_TOOL_NAME} runner failed to start" - ); - return None; - }; - Some(process.worker(exec, tool_runtime)) - } - - pub(crate) async fn allocate_cell_id(&self) -> String { - let mut next_cell_id = self.next_cell_id.lock().await; - let cell_id = *next_cell_id; - *next_cell_id = next_cell_id.saturating_add(1); - cell_id.to_string() - } - - pub(crate) async fn allocate_request_id(&self) -> String { - uuid::Uuid::new_v4().to_string() - } -} diff --git a/codex-rs/core/src/tools/code_mode/wait_description.md b/codex-rs/core/src/tools/code_mode/wait_description.md deleted file mode 100644 index 41b928f514..0000000000 --- a/codex-rs/core/src/tools/code_mode/wait_description.md +++ /dev/null @@ -1,8 +0,0 @@ -- Use `wait` only after `exec` returns `Script running with cell ID ...`. -- `cell_id` identifies the running `exec` cell to resume. -- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout. -- `max_tokens` limits how much new output this wait call returns. -- `terminate: true` stops the running cell instead of waiting for more output. -- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell. -- If the cell is still running, `wait` may yield again with the same `cell_id`. -- If the cell has already finished, `wait` returns the completed result and closes the cell. diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index caaf8c8c44..f319985a88 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -8,13 +8,10 @@ use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use super::CodeModeSessionProgress; use super::DEFAULT_WAIT_YIELD_TIME_MS; use super::ExecContext; -use super::PUBLIC_TOOL_NAME; use super::WAIT_TOOL_NAME; -use super::handle_node_message; -use super::protocol::HostToNodeMessage; +use super::handle_runtime_response; pub struct CodeModeWaitHandler; @@ -63,66 +60,21 @@ impl ToolHandler for CodeModeWaitHandler { ToolPayload::Function { arguments } if tool_name == WAIT_TOOL_NAME => { let args: ExecWaitArgs = parse_arguments(&arguments)?; let exec = ExecContext { session, turn }; - let request_id = exec - .session - .services - .code_mode_service - .allocate_request_id() - .await; let started_at = std::time::Instant::now(); - let message = if args.terminate { - HostToNodeMessage::Terminate { - request_id: request_id.clone(), - cell_id: args.cell_id.clone(), - } - } else { - HostToNodeMessage::Poll { - request_id: request_id.clone(), - cell_id: args.cell_id.clone(), - yield_time_ms: args.yield_time_ms, - } - }; - let process_slot = exec + let response = exec .session .services .code_mode_service - .ensure_started() + .wait(codex_code_mode::WaitRequest { + cell_id: args.cell_id, + yield_time_ms: args.yield_time_ms, + terminate: args.terminate, + }) .await - .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; - let result = { - let mut process_slot = process_slot; - let Some(process) = process_slot.as_mut() else { - return Err(FunctionCallError::RespondToModel(format!( - "{PUBLIC_TOOL_NAME} runner failed to start" - ))); - }; - if !matches!(process.has_exited(), Ok(false)) { - return Err(FunctionCallError::RespondToModel(format!( - "{PUBLIC_TOOL_NAME} runner failed to start" - ))); - } - let message = process - .send(&request_id, &message) - .await - .map_err(|err| err.to_string()); - let message = match message { - Ok(message) => message, - Err(error) => return Err(FunctionCallError::RespondToModel(error)), - }; - handle_node_message( - &exec, - args.cell_id, - message, - Some(args.max_tokens), - started_at, - ) + .map_err(FunctionCallError::RespondToModel)?; + handle_runtime_response(&exec, response, args.max_tokens, started_at) .await - }; - match result { - Ok(CodeModeSessionProgress::Finished(output)) - | Ok(CodeModeSessionProgress::Yielded { output }) => Ok(output), - Err(error) => Err(FunctionCallError::RespondToModel(error)), - } + .map_err(FunctionCallError::RespondToModel) } _ => Err(FunctionCallError::RespondToModel(format!( "{WAIT_TOOL_NAME} expects JSON arguments" diff --git a/codex-rs/core/src/tools/code_mode/worker.rs b/codex-rs/core/src/tools/code_mode/worker.rs deleted file mode 100644 index 5853f3abe3..0000000000 --- a/codex-rs/core/src/tools/code_mode/worker.rs +++ /dev/null @@ -1,116 +0,0 @@ -use tokio::sync::oneshot; -use tokio_util::sync::CancellationToken; -use tracing::error; -use tracing::warn; - -use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseInputItem; - -use super::ExecContext; -use super::PUBLIC_TOOL_NAME; -use super::call_nested_tool; -use super::process::CodeModeProcess; -use super::process::write_message; -use super::protocol::HostToNodeMessage; -use super::protocol::NodeToHostMessage; -use crate::tools::parallel::ToolCallRuntime; - -pub(crate) struct CodeModeWorker { - shutdown_tx: Option>, -} - -impl Drop for CodeModeWorker { - fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - } -} - -impl CodeModeProcess { - pub(super) fn worker( - &self, - exec: ExecContext, - tool_runtime: ToolCallRuntime, - ) -> CodeModeWorker { - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - let stdin = self.stdin.clone(); - let message_rx = self.message_rx.clone(); - tokio::spawn(async move { - loop { - let next_message = tokio::select! { - _ = &mut shutdown_rx => break, - message = async { - let mut message_rx = message_rx.lock().await; - message_rx.recv().await - } => message, - }; - let Some(next_message) = next_message else { - break; - }; - match next_message { - NodeToHostMessage::ToolCall { tool_call } => { - let exec = exec.clone(); - let tool_runtime = tool_runtime.clone(); - let stdin = stdin.clone(); - tokio::spawn(async move { - let result = call_nested_tool( - exec, - tool_runtime, - tool_call.name, - tool_call.input, - CancellationToken::new(), - ) - .await; - let (code_mode_result, error_text) = match result { - Ok(code_mode_result) => (code_mode_result, None), - Err(error) => (serde_json::Value::Null, Some(error.to_string())), - }; - let response = HostToNodeMessage::Response { - request_id: tool_call.request_id, - id: tool_call.id, - code_mode_result, - error_text, - }; - if let Err(err) = write_message(&stdin, &response).await { - warn!("failed to write {PUBLIC_TOOL_NAME} tool response: {err}"); - } - }); - } - NodeToHostMessage::Notify { notify } => { - if notify.text.trim().is_empty() { - continue; - } - if exec - .session - .inject_response_items(vec![ResponseInputItem::CustomToolCallOutput { - call_id: notify.call_id.clone(), - name: Some(PUBLIC_TOOL_NAME.to_string()), - output: FunctionCallOutputPayload::from_text(notify.text), - }]) - .await - .is_err() - { - warn!( - "failed to inject {PUBLIC_TOOL_NAME} notify message for cell {}: no active turn", - notify.cell_id - ); - } - } - unexpected_message @ (NodeToHostMessage::Yielded { .. } - | NodeToHostMessage::Terminated { .. } - | NodeToHostMessage::Result { .. }) => { - error!( - "received unexpected {PUBLIC_TOOL_NAME} message in worker loop: {unexpected_message:?}" - ); - break; - } - } - } - }); - - CodeModeWorker { - shutdown_tx: Some(shutdown_tx), - } - } -} diff --git a/codex-rs/core/src/tools/code_mode_description.rs b/codex-rs/core/src/tools/code_mode_description.rs index b7722aeb72..fb4fc1f519 100644 --- a/codex-rs/core/src/tools/code_mode_description.rs +++ b/codex-rs/core/src/tools/code_mode_description.rs @@ -1,30 +1,11 @@ use crate::client_common::tools::ToolSpec; -use crate::mcp::split_qualified_tool_name; -use crate::tools::code_mode::PUBLIC_TOOL_NAME; -use serde_json::Value as JsonValue; -pub(crate) struct CodeModeToolReference { - pub(crate) module_path: String, - pub(crate) namespace: Vec, - pub(crate) tool_key: String, -} - -pub(crate) fn code_mode_tool_reference(tool_name: &str) -> CodeModeToolReference { - if let Some((server_name, tool_key)) = split_qualified_tool_name(tool_name) { - let namespace = vec!["mcp".to_string(), server_name]; - return CodeModeToolReference { - module_path: format!("tools/{}.js", namespace.join("/")), - namespace, - tool_key, - }; - } - - CodeModeToolReference { - module_path: "tools.js".to_string(), - namespace: Vec::new(), - tool_key: tool_name.to_string(), - } -} +#[allow(unused_imports)] +#[cfg(test)] +pub(crate) use codex_code_mode::append_code_mode_sample; +#[allow(unused_imports)] +#[cfg(test)] +pub(crate) use codex_code_mode::render_json_schema_to_typescript; pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: bool) -> ToolSpec { if !code_mode_enabled { @@ -33,27 +14,27 @@ pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: match spec { ToolSpec::Function(mut tool) => { - if tool.name != PUBLIC_TOOL_NAME { - tool.description = append_code_mode_sample( - &tool.description, - &tool.name, - "args", - serde_json::to_value(&tool.parameters) - .ok() - .as_ref() - .map(render_json_schema_to_typescript) - .unwrap_or_else(|| "unknown".to_string()), - tool.output_schema - .as_ref() - .map(render_json_schema_to_typescript) - .unwrap_or_else(|| "unknown".to_string()), - ); - } + let input_type = serde_json::to_value(&tool.parameters) + .ok() + .map(|schema| codex_code_mode::render_json_schema_to_typescript(&schema)) + .unwrap_or_else(|| "unknown".to_string()); + let output_type = tool + .output_schema + .as_ref() + .map(codex_code_mode::render_json_schema_to_typescript) + .unwrap_or_else(|| "unknown".to_string()); + tool.description = codex_code_mode::append_code_mode_sample( + &tool.description, + &tool.name, + "args", + input_type, + output_type, + ); ToolSpec::Function(tool) } ToolSpec::Freeform(mut tool) => { - if tool.name != PUBLIC_TOOL_NAME { - tool.description = append_code_mode_sample( + if tool.name != codex_code_mode::PUBLIC_TOOL_NAME { + tool.description = codex_code_mode::append_code_mode_sample( &tool.description, &tool.name, "input", @@ -66,234 +47,3 @@ pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: other => other, } } - -fn append_code_mode_sample( - description: &str, - tool_name: &str, - input_name: &str, - input_type: String, - output_type: String, -) -> String { - let declaration = format!( - "declare const tools: {{ {} }};", - render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type) - ); - format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```") -} - -fn render_code_mode_tool_declaration( - tool_name: &str, - input_name: &str, - input_type: String, - output_type: String, -) -> String { - let tool_name = normalize_code_mode_identifier(tool_name); - format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;") -} - -pub(crate) fn normalize_code_mode_identifier(tool_key: &str) -> String { - let mut identifier = String::new(); - - for (index, ch) in tool_key.chars().enumerate() { - let is_valid = if index == 0 { - ch == '_' || ch == '$' || ch.is_ascii_alphabetic() - } else { - ch == '_' || ch == '$' || ch.is_ascii_alphanumeric() - }; - - if is_valid { - identifier.push(ch); - } else { - identifier.push('_'); - } - } - - if identifier.is_empty() { - "_".to_string() - } else { - identifier - } -} - -fn render_json_schema_to_typescript(schema: &JsonValue) -> String { - render_json_schema_to_typescript_inner(schema) -} - -fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String { - match schema { - JsonValue::Bool(true) => "unknown".to_string(), - JsonValue::Bool(false) => "never".to_string(), - JsonValue::Object(map) => { - if let Some(value) = map.get("const") { - return render_json_schema_literal(value); - } - - if let Some(values) = map.get("enum").and_then(serde_json::Value::as_array) { - let rendered = values - .iter() - .map(render_json_schema_literal) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" | "); - } - } - - for key in ["anyOf", "oneOf"] { - if let Some(variants) = map.get(key).and_then(serde_json::Value::as_array) { - let rendered = variants - .iter() - .map(render_json_schema_to_typescript_inner) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" | "); - } - } - } - - if let Some(variants) = map.get("allOf").and_then(serde_json::Value::as_array) { - let rendered = variants - .iter() - .map(render_json_schema_to_typescript_inner) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" & "); - } - } - - if let Some(schema_type) = map.get("type") { - if let Some(types) = schema_type.as_array() { - let rendered = types - .iter() - .filter_map(serde_json::Value::as_str) - .map(|schema_type| render_json_schema_type_keyword(map, schema_type)) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" | "); - } - } - - if let Some(schema_type) = schema_type.as_str() { - return render_json_schema_type_keyword(map, schema_type); - } - } - - if map.contains_key("properties") - || map.contains_key("additionalProperties") - || map.contains_key("required") - { - return render_json_schema_object(map); - } - - if map.contains_key("items") || map.contains_key("prefixItems") { - return render_json_schema_array(map); - } - - "unknown".to_string() - } - _ => "unknown".to_string(), - } -} - -fn render_json_schema_type_keyword( - map: &serde_json::Map, - schema_type: &str, -) -> String { - match schema_type { - "string" => "string".to_string(), - "number" | "integer" => "number".to_string(), - "boolean" => "boolean".to_string(), - "null" => "null".to_string(), - "array" => render_json_schema_array(map), - "object" => render_json_schema_object(map), - _ => "unknown".to_string(), - } -} - -fn render_json_schema_array(map: &serde_json::Map) -> String { - if let Some(items) = map.get("items") { - let item_type = render_json_schema_to_typescript_inner(items); - return format!("Array<{item_type}>"); - } - - if let Some(items) = map.get("prefixItems").and_then(serde_json::Value::as_array) { - let item_types = items - .iter() - .map(render_json_schema_to_typescript_inner) - .collect::>(); - if !item_types.is_empty() { - return format!("[{}]", item_types.join(", ")); - } - } - - "unknown[]".to_string() -} - -fn render_json_schema_object(map: &serde_json::Map) -> String { - let required = map - .get("required") - .and_then(serde_json::Value::as_array) - .map(|items| { - items - .iter() - .filter_map(serde_json::Value::as_str) - .collect::>() - }) - .unwrap_or_default(); - let properties = map - .get("properties") - .and_then(serde_json::Value::as_object) - .cloned() - .unwrap_or_default(); - - let mut sorted_properties = properties.iter().collect::>(); - sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); - let mut lines = sorted_properties - .into_iter() - .map(|(name, value)| { - let optional = if required.iter().any(|required_name| required_name == name) { - "" - } else { - "?" - }; - let property_name = render_json_schema_property_name(name); - let property_type = render_json_schema_to_typescript_inner(value); - format!("{property_name}{optional}: {property_type};") - }) - .collect::>(); - - if let Some(additional_properties) = map.get("additionalProperties") { - let additional_type = match additional_properties { - JsonValue::Bool(true) => Some("unknown".to_string()), - JsonValue::Bool(false) => None, - value => Some(render_json_schema_to_typescript_inner(value)), - }; - - if let Some(additional_type) = additional_type { - lines.push(format!("[key: string]: {additional_type};")); - } - } else if properties.is_empty() { - lines.push("[key: string]: unknown;".to_string()); - } - - if lines.is_empty() { - return "{}".to_string(); - } - - format!("{{ {} }}", lines.join(" ")) -} - -fn render_json_schema_property_name(name: &str) -> String { - if normalize_code_mode_identifier(name) == name { - name.to_string() - } else { - serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\""))) - } -} - -fn render_json_schema_literal(value: &JsonValue) -> String { - serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string()) -} - -#[cfg(test)] -#[path = "code_mode_description_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 8544eb404a..345f7ce06f 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -4,7 +4,6 @@ use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; use crate::mcp_connection_manager::ToolInfo; use crate::sandboxing::SandboxPermissions; -use crate::tools::code_mode::is_code_mode_nested_tool; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -67,7 +66,7 @@ impl ToolRouter { specs .iter() .filter_map(|configured_tool| { - if !is_code_mode_nested_tool(configured_tool.spec.name()) { + if !codex_code_mode::is_code_mode_nested_tool(configured_tool.spec.name()) { Some(configured_tool.spec.clone()) } else { None diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 5ae2f333df..2e0a413a6f 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -11,9 +11,6 @@ use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::code_mode::PUBLIC_TOOL_NAME; use crate::tools::code_mode::WAIT_TOOL_NAME; -use crate::tools::code_mode::is_code_mode_nested_tool; -use crate::tools::code_mode::tool_description as code_mode_tool_description; -use crate::tools::code_mode::wait_tool_description as code_mode_wait_tool_description; use crate::tools::code_mode_description::augment_tool_spec_for_code_mode; use crate::tools::discoverable::DiscoverablePluginInfo; use crate::tools::discoverable::DiscoverableTool; @@ -833,7 +830,7 @@ fn create_wait_tool() -> ToolSpec { name: WAIT_TOOL_NAME.to_string(), description: format!( "Waits on a yielded `{PUBLIC_TOOL_NAME}` cell and returns new output or completion.\n{}", - code_mode_wait_tool_description().trim() + codex_code_mode::build_wait_tool_description().trim() ), strict: false, parameters: JsonSchema::Object { @@ -2176,7 +2173,10 @@ SOURCE: /[\s\S]+/ ToolSpec::Freeform(FreeformTool { name: PUBLIC_TOOL_NAME.to_string(), - description: code_mode_tool_description(enabled_tools, code_mode_only_enabled), + description: codex_code_mode::build_exec_tool_description( + enabled_tools, + code_mode_only_enabled, + ), format: FreeformToolFormat { r#type: "grammar".to_string(), syntax: "lark".to_string(), @@ -2647,7 +2647,7 @@ pub(crate) fn build_specs_with_discoverable_tools( ToolSpec::Freeform(tool) => (tool.name, tool.description), _ => return None, }; - is_code_mode_nested_tool(&name).then_some((name, description)) + codex_code_mode::is_code_mode_nested_tool(&name).then_some((name, description)) }) .collect::>(); enabled_tools.sort_by(|left, right| left.0.cmp(&right.0)); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index c74de38e86..b9e4f05b36 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -1672,8 +1672,6 @@ async fn code_mode_exit_stops_script_immediately() -> Result<()> { &server, "use exec to stop script early with exit helper", r#" -import { exit, text } from "@openai/code_mode"; - text("before"); exit(); text("after"); @@ -2129,6 +2127,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "SuppressedError", "Symbol", "SyntaxError", + "Temporal", "TypeError", "URIError", "Uint16Array", @@ -2141,7 +2140,6 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "WebAssembly", "__codexContentItems", "add_content", - "console", "decodeURI", "decodeURIComponent", "encodeURI", @@ -2282,10 +2280,8 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> { test.session_configured = new_thread.session_configured; let code = r#" -import { ALL_TOOLS, hidden_dynamic_tool } from "tools.js"; - const tool = ALL_TOOLS.find(({ name }) => name === "hidden_dynamic_tool"); -const out = await hidden_dynamic_tool({ city: "Paris" }); +const out = await tools.hidden_dynamic_tool({ city: "Paris" }); text( JSON.stringify({ name: tool?.name ?? null, diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 1e6073be09..7252d9a6b6 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -159,7 +159,9 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { let call_id = "uexec-apply-patch"; let args = json!({ "cmd": command, - "yield_time_ms": 250, + // The intercepted apply_patch path spawns a helper process, which can + // take longer than a tiny unified-exec yield deadline on CI. + "yield_time_ms": 5_000, }); let responses = vec![ From 06e06ab173a7912de1661f6678eaf8d1c04da170 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Sat, 21 Mar 2026 00:29:29 -0700 Subject: [PATCH 02/38] [plugins] Fix plugin explicit mention context management. (#15372) - [x] Fix plugin explicit mention context management. --- codex-rs/core/src/codex.rs | 16 +--------------- codex-rs/core/src/codex_tests.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 12270bb6dd..0bfb88c55c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5510,20 +5510,6 @@ pub(crate) async fn run_turn( &available_connectors, &skill_name_counts_lower, )); - // Explicit plugin mentions can make a plugin's enabled apps callable for - // this turn without persisting those connectors as sticky user selections. - let mut turn_enabled_connectors = explicitly_enabled_connectors.clone(); - turn_enabled_connectors.extend( - mentioned_plugins - .iter() - .flat_map(|plugin| plugin.app_connector_ids.iter()) - .map(|connector_id| connector_id.0.clone()) - .filter(|connector_id| { - available_connectors - .iter() - .any(|connector| connector.is_enabled && connector.id == *connector_id) - }), - ); let connector_names_by_id = available_connectors .iter() .map(|connector| (connector.id.as_str(), connector.name.as_str())) @@ -5675,7 +5661,7 @@ pub(crate) async fn run_turn( &mut client_session, turn_metadata_header.as_deref(), sampling_request_input, - &turn_enabled_connectors, + &explicitly_enabled_connectors, skills_outcome, &mut server_model_warning_emitted_for_turn, cancellation_token.child_token(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a5412eff29..c7a715cd93 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -641,6 +641,21 @@ fn filter_connectors_for_input_skips_disabled_connectors() { assert_eq!(selected, Vec::new()); } +#[test] +fn filter_connectors_for_input_skips_plugin_mentions() { + let connectors = vec![make_connector("figma", "Figma")]; + let input = vec![user_message("use [@figma](plugin://figma@openai-curated)")]; + let explicitly_enabled_connectors = HashSet::new(); + let selected = filter_connectors_for_input( + &connectors, + &input, + &explicitly_enabled_connectors, + &HashMap::new(), + ); + + assert_eq!(selected, Vec::new()); +} + #[test] fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() { let connectors = vec![make_connector("calendar", "Calendar")]; From 0d9bb8ea58a7708fca242f35db647e8665771609 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sat, 21 Mar 2026 09:31:22 -0700 Subject: [PATCH 03/38] chore(context) Include guardian approval context (#15366) ## Summary Include the guardian context in the developer message for approvals ## Testing - [x] Updated unit tests --- codex-rs/core/src/codex.rs | 1 + codex-rs/core/src/context_manager/updates.rs | 1 + .../core/tests/suite/permissions_messages.rs | 1 + codex-rs/protocol/src/models.rs | 158 +++++++++++++----- 4 files changed, 121 insertions(+), 40 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 0bfb88c55c..cbaabe6b84 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3439,6 +3439,7 @@ impl Session { DeveloperInstructions::from_policy( turn_context.sandbox_policy.get(), turn_context.approval_policy.value(), + turn_context.config.approvals_reviewer, self.services.exec_policy.current().as_ref(), &turn_context.cwd, turn_context diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 871cf502aa..c122187901 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -43,6 +43,7 @@ fn build_permissions_update_item( Some(DeveloperInstructions::from_policy( next.sandbox_policy.get(), next.approval_policy.value(), + next.config.approvals_reviewer, exec_policy, &next.cwd, next.features.enabled(Feature::ExecPermissionApprovals), diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index cdf69acdc0..2838233e7f 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -493,6 +493,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let expected = DeveloperInstructions::from_policy( &sandbox_policy, AskForApproval::OnRequest, + test.config.approvals_reviewer, &Policy::empty(), test.config.cwd.as_path(), false, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 8fe93e6779..5c68d3c3e3 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -9,6 +9,7 @@ use serde::Serialize; use serde::ser::Serializer; use ts_rs::TS; +use crate::config_types::ApprovalsReviewer; use crate::config_types::CollaborationMode; use crate::config_types::SandboxMode; use crate::protocol::AskForApproval; @@ -481,6 +482,7 @@ const APPROVAL_POLICY_ON_REQUEST_RULE: &str = include_str!("prompts/permissions/approval_policy/on_request.md"); const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str = include_str!("prompts/permissions/approval_policy/on_request_rule_request_permission.md"); +const GUARDIAN_SUBAGENT_APPROVAL_SUFFIX: &str = "`approvals_reviewer` is `guardian_subagent`: Sandbox escalations with require_escalated will be reviewed for compliance with the policy. If a rejection happens, you should proceed only with a materially safer alternative, or inform the user of the risk and send a final message to ask for approval."; const SANDBOX_MODE_DANGER_FULL_ACCESS: &str = include_str!("prompts/permissions/sandbox_mode/danger_full_access.md"); @@ -491,6 +493,14 @@ const SANDBOX_MODE_READ_ONLY: &str = include_str!("prompts/permissions/sandbox_m const REALTIME_START_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtime_start.md"); const REALTIME_END_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtime_end.md"); +struct PermissionsPromptConfig<'a> { + approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, + exec_policy: &'a Policy, + exec_permission_approvals_enabled: bool, + request_permissions_tool_enabled: bool, +} + impl DeveloperInstructions { pub fn new>(text: T) -> Self { Self { text: text.into() } @@ -498,6 +508,7 @@ impl DeveloperInstructions { pub fn from( approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, exec_policy: &Policy, exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, @@ -541,6 +552,14 @@ impl DeveloperInstructions { ), }; + let text = if approvals_reviewer == ApprovalsReviewer::GuardianSubagent + && approval_policy != AskForApproval::Never + { + format!("{text}\n\n{GUARDIAN_SUBAGENT_APPROVAL_SUFFIX}") + } else { + text + }; + DeveloperInstructions::new(text) } @@ -590,6 +609,7 @@ impl DeveloperInstructions { pub fn from_policy( sandbox_policy: &SandboxPolicy, approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, exec_policy: &Policy, cwd: &Path, exec_permission_approvals_enabled: bool, @@ -614,11 +634,14 @@ impl DeveloperInstructions { DeveloperInstructions::from_permissions_with_network( sandbox_mode, network_access, - approval_policy, - exec_policy, + PermissionsPromptConfig { + approval_policy, + approvals_reviewer, + exec_policy, + exec_permission_approvals_enabled, + request_permissions_tool_enabled, + }, writable_roots, - exec_permission_approvals_enabled, - request_permissions_tool_enabled, ) } @@ -639,11 +662,8 @@ impl DeveloperInstructions { fn from_permissions_with_network( sandbox_mode: SandboxMode, network_access: NetworkAccess, - approval_policy: AskForApproval, - exec_policy: &Policy, + config: PermissionsPromptConfig<'_>, writable_roots: Option>, - exec_permission_approvals_enabled: bool, - request_permissions_tool_enabled: bool, ) -> Self { let start_tag = DeveloperInstructions::new(""); let end_tag = DeveloperInstructions::new(""); @@ -653,10 +673,11 @@ impl DeveloperInstructions { network_access, )) .concat(DeveloperInstructions::from( - approval_policy, - exec_policy, - exec_permission_approvals_enabled, - request_permissions_tool_enabled, + config.approval_policy, + config.approvals_reviewer, + config.exec_policy, + config.exec_permission_approvals_enabled, + config.request_permissions_tool_enabled, )) .concat(DeveloperInstructions::from_writable_roots(writable_roots)) .concat(end_tag) @@ -1923,11 +1944,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: false, + }, None, - false, - false, ); let text = instructions.into_text(); @@ -1954,6 +1978,7 @@ mod tests { let instructions = DeveloperInstructions::from_policy( &policy, AskForApproval::UnlessTrusted, + ApprovalsReviewer::User, &Policy::empty(), &PathBuf::from("/tmp"), false, @@ -1976,11 +2001,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &exec_policy, + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &exec_policy, + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: false, + }, None, - false, - false, ); let text = instructions.into_text(); @@ -1994,11 +2022,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::UnlessTrusted, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::UnlessTrusted, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: true, + }, None, - false, - true, ); let text = instructions.into_text(); @@ -2011,11 +2042,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnFailure, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnFailure, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: true, + }, None, - false, - true, ); let text = instructions.into_text(); @@ -2028,11 +2062,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: true, + request_permissions_tool_enabled: false, + }, None, - true, - false, ); let text = instructions.into_text(); @@ -2045,11 +2082,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: true, + }, None, - false, - true, ); let text = instructions.into_text(); @@ -2064,11 +2104,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: true, + request_permissions_tool_enabled: true, + }, None, - true, - true, ); let text = instructions.into_text(); @@ -2076,6 +2119,35 @@ mod tests { assert!(text.contains("# request_permissions Tool")); } + #[test] + fn guardian_subagent_approvals_append_guardian_specific_guidance() { + let text = DeveloperInstructions::from( + AskForApproval::OnRequest, + ApprovalsReviewer::GuardianSubagent, + &Policy::empty(), + false, + false, + ) + .into_text(); + + assert!(text.contains("`approvals_reviewer` is `guardian_subagent`")); + assert!(text.contains("materially safer alternative")); + } + + #[test] + fn guardian_subagent_approvals_omit_guardian_specific_guidance_when_approval_is_never() { + let text = DeveloperInstructions::from( + AskForApproval::Never, + ApprovalsReviewer::GuardianSubagent, + &Policy::empty(), + false, + false, + ) + .into_text(); + + assert!(!text.contains("`approvals_reviewer` is `guardian_subagent`")); + } + fn granular_categories_section(title: &str, categories: &[&str]) -> String { format!("{title}\n{}", categories.join("\n")) } @@ -2118,6 +2190,7 @@ mod tests { request_permissions: true, mcp_elicitations: false, }), + ApprovalsReviewer::User, &Policy::empty(), true, false, @@ -2151,6 +2224,7 @@ mod tests { request_permissions: true, mcp_elicitations: true, }), + ApprovalsReviewer::User, &Policy::empty(), true, false, @@ -2183,6 +2257,7 @@ mod tests { request_permissions: true, mcp_elicitations: true, }), + ApprovalsReviewer::User, &Policy::empty(), false, false, @@ -2215,6 +2290,7 @@ mod tests { request_permissions: true, mcp_elicitations: true, }), + ApprovalsReviewer::User, &Policy::empty(), true, true, @@ -2230,6 +2306,7 @@ mod tests { request_permissions: false, mcp_elicitations: true, }), + ApprovalsReviewer::User, &Policy::empty(), true, true, @@ -2249,6 +2326,7 @@ mod tests { request_permissions: true, mcp_elicitations: false, }), + ApprovalsReviewer::User, &Policy::empty(), true, false, From b0236501e2c19164d47bda54cf523ea39bdba98b Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 21 Mar 2026 12:29:33 -0600 Subject: [PATCH 04/38] Remove legacy app-server notification handling from tui_app_server (#15390) As part of moving the TUI onto the app server, we added some temporary handling of some legacy events. We've confirmed that these do not need to be supported, so this PR removes this support from the tui_app_server, allowing for additional simplifications in follow-on PRs. These events are needed only for very old rollouts. None of the other app server clients (IDE extension or app) support these either. ## Summary - stop translating legacy `codex/event/*` notifications inside `tui_app_server` - remove the TUI-side legacy warning and rollback buffering/replay paths that were only fed by those notifications - keep the lower-level app-server and app-server-client legacy event plumbing intact so PR #15106 can rebase on top and handle the remaining exec/lower-layer migration separately --- codex-rs/tui_app_server/src/app.rs | 354 +----------------- .../src/app/app_server_adapter.rs | 135 +------ codex-rs/tui_app_server/src/chatwidget.rs | 4 - 3 files changed, 12 insertions(+), 481 deletions(-) diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 171df69270..d4569ae7a8 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -464,8 +464,6 @@ enum ThreadBufferedEvent { Notification(ServerNotification), Request(ServerRequest), HistoryEntryResponse(GetHistoryEntryResponseEvent), - LegacyWarning(String), - LegacyRollback { num_turns: u32 }, } #[derive(Debug)] @@ -474,7 +472,6 @@ struct ThreadEventStore { turns: Vec, buffer: VecDeque, pending_interactive_replay: PendingInteractiveReplayState, - pending_local_legacy_rollbacks: VecDeque, active_turn_id: Option, input_state: Option, capacity: usize, @@ -483,10 +480,7 @@ struct ThreadEventStore { impl ThreadEventStore { fn event_survives_session_refresh(event: &ThreadBufferedEvent) -> bool { - matches!( - event, - ThreadBufferedEvent::Request(_) | ThreadBufferedEvent::LegacyWarning(_) - ) + matches!(event, ThreadBufferedEvent::Request(_)) } fn new(capacity: usize) -> Self { @@ -495,7 +489,6 @@ impl ThreadEventStore { turns: Vec::new(), buffer: VecDeque::new(), pending_interactive_replay: PendingInteractiveReplayState::default(), - pending_local_legacy_rollbacks: VecDeque::new(), active_turn_id: None, input_state: None, capacity, @@ -521,7 +514,6 @@ impl ThreadEventStore { } fn set_turns(&mut self, turns: Vec) { - self.pending_local_legacy_rollbacks.clear(); self.active_turn_id = turns .iter() .rev() @@ -578,37 +570,6 @@ impl ThreadEventStore { self.active_turn_id = None; } - fn note_local_thread_rollback(&mut self, num_turns: u32) { - self.pending_local_legacy_rollbacks.push_back(num_turns); - while self.pending_local_legacy_rollbacks.len() > self.capacity { - self.pending_local_legacy_rollbacks.pop_front(); - } - } - - fn consume_pending_local_legacy_rollback(&mut self, num_turns: u32) -> bool { - match self.pending_local_legacy_rollbacks.front() { - Some(pending_num_turns) if *pending_num_turns == num_turns => { - self.pending_local_legacy_rollbacks.pop_front(); - true - } - _ => false, - } - } - - fn apply_legacy_thread_rollback(&mut self, num_turns: u32) { - let num_turns = usize::try_from(num_turns).unwrap_or(usize::MAX); - if num_turns >= self.turns.len() { - self.turns.clear(); - } else { - self.turns - .truncate(self.turns.len().saturating_sub(num_turns)); - } - self.buffer.clear(); - self.pending_interactive_replay = PendingInteractiveReplayState::default(); - self.pending_local_legacy_rollbacks.clear(); - self.active_turn_id = None; - } - fn snapshot(&self) -> ThreadEventSnapshot { ThreadEventSnapshot { session: self.session.clone(), @@ -623,9 +584,7 @@ impl ThreadEventStore { .pending_interactive_replay .should_replay_snapshot_request(request), ThreadBufferedEvent::Notification(_) - | ThreadBufferedEvent::HistoryEntryResponse(_) - | ThreadBufferedEvent::LegacyWarning(_) - | ThreadBufferedEvent::LegacyRollback { .. } => true, + | ThreadBufferedEvent::HistoryEntryResponse(_) => true, }) .cloned() .collect(), @@ -2283,50 +2242,6 @@ impl App { Ok(()) } - async fn enqueue_thread_legacy_warning( - &mut self, - thread_id: ThreadId, - message: String, - ) -> Result<()> { - let (sender, store) = { - let channel = self.ensure_thread_channel(thread_id); - (channel.sender.clone(), Arc::clone(&channel.store)) - }; - - let should_send = { - let mut guard = store.lock().await; - guard - .buffer - .push_back(ThreadBufferedEvent::LegacyWarning(message.clone())); - if guard.buffer.len() > guard.capacity - && let Some(removed) = guard.buffer.pop_front() - && let ThreadBufferedEvent::Request(request) = &removed - { - guard - .pending_interactive_replay - .note_evicted_server_request(request); - } - guard.active - }; - - if should_send { - match sender.try_send(ThreadBufferedEvent::LegacyWarning(message)) { - Ok(()) => {} - Err(TrySendError::Full(event)) => { - tokio::spawn(async move { - if let Err(err) = sender.send(event).await { - tracing::warn!("thread {thread_id} event channel closed: {err}"); - } - }); - } - Err(TrySendError::Closed(_)) => { - tracing::warn!("thread {thread_id} event channel closed"); - } - } - } - Ok(()) - } - async fn enqueue_thread_history_entry_response( &mut self, thread_id: ThreadId, @@ -2371,64 +2286,6 @@ impl App { Ok(()) } - async fn enqueue_thread_legacy_rollback( - &mut self, - thread_id: ThreadId, - num_turns: u32, - ) -> Result<()> { - let (sender, store) = { - let channel = self.ensure_thread_channel(thread_id); - (channel.sender.clone(), Arc::clone(&channel.store)) - }; - - let should_send = { - let mut guard = store.lock().await; - if guard.consume_pending_local_legacy_rollback(num_turns) { - false - } else { - guard.apply_legacy_thread_rollback(num_turns); - guard.active - } - }; - - if should_send { - match sender.try_send(ThreadBufferedEvent::LegacyRollback { num_turns }) { - Ok(()) => {} - Err(TrySendError::Full(event)) => { - tokio::spawn(async move { - if let Err(err) = sender.send(event).await { - tracing::warn!("thread {thread_id} event channel closed: {err}"); - } - }); - } - Err(TrySendError::Closed(_)) => { - tracing::warn!("thread {thread_id} event channel closed"); - } - } - } - Ok(()) - } - - async fn enqueue_primary_thread_legacy_warning(&mut self, message: String) -> Result<()> { - if let Some(thread_id) = self.primary_thread_id { - return self.enqueue_thread_legacy_warning(thread_id, message).await; - } - self.pending_primary_events - .push_back(ThreadBufferedEvent::LegacyWarning(message)); - Ok(()) - } - - async fn enqueue_primary_thread_legacy_rollback(&mut self, num_turns: u32) -> Result<()> { - if let Some(thread_id) = self.primary_thread_id { - return self - .enqueue_thread_legacy_rollback(thread_id, num_turns) - .await; - } - self.pending_primary_events - .push_back(ThreadBufferedEvent::LegacyRollback { num_turns }); - Ok(()) - } - async fn enqueue_primary_thread_session( &mut self, session: ThreadSessionState, @@ -2466,14 +2323,6 @@ impl App { self.enqueue_thread_history_entry_response(thread_id, event) .await?; } - ThreadBufferedEvent::LegacyWarning(message) => { - self.enqueue_thread_legacy_warning(thread_id, message) - .await?; - } - ThreadBufferedEvent::LegacyRollback { num_turns } => { - self.enqueue_thread_legacy_rollback(thread_id, num_turns) - .await?; - } } } self.chat_widget @@ -4769,7 +4618,6 @@ impl App { if let Some(channel) = self.thread_event_channels.get(&thread_id) { let mut store = channel.store.lock().await; store.apply_thread_rollback(response); - store.note_local_thread_rollback(num_turns); } if self.active_thread_id == Some(thread_id) && let Some(mut rx) = self.active_thread_rx.take() @@ -4814,13 +4662,6 @@ impl App { ThreadBufferedEvent::HistoryEntryResponse(event) => { self.chat_widget.handle_history_entry_response(event); } - ThreadBufferedEvent::LegacyWarning(message) => { - self.chat_widget.add_warning_message(message); - } - ThreadBufferedEvent::LegacyRollback { num_turns } => { - self.handle_backtrack_rollback_succeeded(num_turns); - self.chat_widget.handle_thread_rolled_back(); - } } if needs_refresh { self.refresh_status_line(); @@ -4838,13 +4679,6 @@ impl App { ThreadBufferedEvent::HistoryEntryResponse(event) => { self.chat_widget.handle_history_entry_response(event) } - ThreadBufferedEvent::LegacyWarning(message) => { - self.chat_widget.add_warning_message(message); - } - ThreadBufferedEvent::LegacyRollback { num_turns } => { - self.handle_backtrack_rollback_succeeded(num_turns); - self.chat_widget.handle_thread_rolled_back(); - } } } @@ -5335,6 +5169,7 @@ mod tests { use codex_app_server_protocol::AdditionalPermissionProfile; use codex_app_server_protocol::AgentMessageDeltaNotification; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; + use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol; use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment; @@ -5935,33 +5770,6 @@ mod tests { } } - #[tokio::test] - async fn replay_thread_snapshot_replays_legacy_warning_history() { - let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; - - app.replay_thread_snapshot( - ThreadEventSnapshot { - session: None, - turns: Vec::new(), - events: vec![ThreadBufferedEvent::LegacyWarning( - "legacy warning message".to_string(), - )], - input_state: None, - }, - false, - ); - - let mut saw_warning = false; - while let Ok(event) = app_event_rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - let transcript = lines_to_single_string(&cell.transcript_lines(80)); - saw_warning |= transcript.contains("legacy warning message"); - } - } - - assert!(saw_warning, "expected replayed legacy warning history cell"); - } - #[tokio::test] async fn replay_only_thread_keeps_restored_queue_visible() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -7308,87 +7116,6 @@ guardian_approval = true Ok(()) } - #[tokio::test] - async fn legacy_warning_eviction_clears_pending_interactive_replay_state() -> Result<()> { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - let channel = ThreadEventChannel::new(1); - { - let mut store = channel.store.lock().await; - store.push_request(exec_approval_request( - thread_id, - "turn-approval", - "call-approval", - None, - )); - assert_eq!(store.has_pending_thread_approvals(), true); - } - app.thread_event_channels.insert(thread_id, channel); - - app.enqueue_thread_legacy_warning(thread_id, "legacy warning".to_string()) - .await?; - - let store = app - .thread_event_channels - .get(&thread_id) - .expect("thread store should exist") - .store - .lock() - .await; - assert_eq!(store.has_pending_thread_approvals(), false); - let snapshot = store.snapshot(); - assert_eq!(snapshot.events.len(), 1); - assert!(matches!( - snapshot.events.first(), - Some(ThreadBufferedEvent::LegacyWarning(message)) if message == "legacy warning" - )); - - Ok(()) - } - - #[tokio::test] - async fn legacy_thread_rollback_trims_inactive_thread_snapshot_state() -> Result<()> { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); - let turns = vec![ - test_turn("turn-1", TurnStatus::Completed, Vec::new()), - test_turn("turn-2", TurnStatus::Completed, Vec::new()), - ]; - let channel = ThreadEventChannel::new_with_session(4, session, turns); - { - let mut store = channel.store.lock().await; - store.push_request(exec_approval_request( - thread_id, - "turn-approval", - "call-approval", - None, - )); - assert_eq!(store.has_pending_thread_approvals(), true); - } - app.thread_event_channels.insert(thread_id, channel); - - app.enqueue_thread_legacy_rollback(thread_id, 1).await?; - - let store = app - .thread_event_channels - .get(&thread_id) - .expect("thread store should exist") - .store - .lock() - .await; - assert_eq!( - store.turns, - vec![test_turn("turn-1", TurnStatus::Completed, Vec::new())] - ); - assert_eq!(store.has_pending_thread_approvals(), false); - let snapshot = store.snapshot(); - assert_eq!(snapshot.turns, store.turns); - assert!(snapshot.events.is_empty()); - - Ok(()) - } - #[tokio::test] async fn inactive_thread_started_notification_initializes_replay_session() -> Result<()> { let mut app = make_test_app().await; @@ -8108,16 +7835,6 @@ guardian_approval = true assert_eq!(store.has_pending_thread_approvals(), false); } - #[test] - fn thread_event_store_consumes_matching_local_legacy_rollback_once() { - let mut store = ThreadEventStore::new(8); - store.note_local_thread_rollback(2); - - assert!(store.consume_pending_local_legacy_rollback(2)); - assert!(!store.consume_pending_local_legacy_rollback(2)); - assert!(!store.consume_pending_local_legacy_rollback(1)); - } - fn next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { let mut seen = Vec::new(); while let Ok(op) = op_rx.try_recv() { @@ -9076,8 +8793,13 @@ guardian_approval = true let (tx, rx) = mpsc::channel(8); app.active_thread_id = Some(thread_id); app.active_thread_rx = Some(rx); - tx.send(ThreadBufferedEvent::LegacyWarning( - "stale warning".to_string(), + tx.send(ThreadBufferedEvent::Notification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "stale warning".to_string(), + details: None, + path: None, + range: None, + }), )) .await .expect("event should queue"); @@ -9115,62 +8837,6 @@ guardian_approval = true assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); } - #[tokio::test] - async fn local_rollback_response_suppresses_matching_legacy_rollback() { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); - let initial_turns = vec![ - test_turn("turn-1", TurnStatus::Completed, Vec::new()), - test_turn("turn-2", TurnStatus::Completed, Vec::new()), - ]; - app.thread_event_channels.insert( - thread_id, - ThreadEventChannel::new_with_session(8, session, initial_turns), - ); - - app.handle_thread_rollback_response( - thread_id, - 1, - &ThreadRollbackResponse { - thread: Thread { - id: thread_id.to_string(), - preview: String::new(), - ephemeral: false, - model_provider: "openai".to_string(), - created_at: 0, - updated_at: 0, - status: codex_app_server_protocol::ThreadStatus::Idle, - path: None, - cwd: PathBuf::from("/tmp/project"), - cli_version: "0.0.0".to_string(), - source: SessionSource::Cli.into(), - agent_nickname: None, - agent_role: None, - git_info: None, - name: None, - turns: vec![test_turn("turn-1", TurnStatus::Completed, Vec::new())], - }, - }, - ) - .await; - - app.enqueue_thread_legacy_rollback(thread_id, 1) - .await - .expect("legacy rollback should not fail"); - - let store = app - .thread_event_channels - .get(&thread_id) - .expect("thread channel") - .store - .lock() - .await; - let snapshot = store.snapshot(); - assert_eq!(snapshot.turns.len(), 1); - assert!(snapshot.events.is_empty()); - } - #[tokio::test] async fn new_session_requests_shutdown_for_previous_conversation() { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index c144e36dbe..26aff5ae3b 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -21,7 +21,6 @@ use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -106,16 +105,9 @@ use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; #[cfg(test)] use codex_protocol::protocol::TurnStartedEvent; -use serde_json::Value; #[cfg(test)] use std::time::Duration; -#[derive(Debug, PartialEq, Eq)] -enum LegacyThreadNotification { - Warning(String), - Rollback { num_turns: u32 }, -} - impl App { pub(super) async fn handle_app_server_event( &mut self, @@ -133,37 +125,8 @@ impl App { self.handle_server_notification_event(app_server_client, notification) .await; } - AppServerEvent::LegacyNotification(notification) => { - if let Some((thread_id, legacy_notification)) = - legacy_thread_notification(notification) - { - let result = match legacy_notification { - LegacyThreadNotification::Warning(message) => { - if self.primary_thread_id == Some(thread_id) - || self.primary_thread_id.is_none() - { - self.enqueue_primary_thread_legacy_warning(message).await - } else { - self.enqueue_thread_legacy_warning(thread_id, message).await - } - } - LegacyThreadNotification::Rollback { num_turns } => { - if self.primary_thread_id == Some(thread_id) - || self.primary_thread_id.is_none() - { - self.enqueue_primary_thread_legacy_rollback(num_turns).await - } else { - self.enqueue_thread_legacy_rollback(thread_id, num_turns) - .await - } - } - }; - if let Err(err) = result { - tracing::warn!("failed to enqueue app-server legacy notification: {err}"); - } - } else { - tracing::debug!("ignoring legacy app-server notification in tui_app_server"); - } + AppServerEvent::LegacyNotification(_) => { + tracing::debug!("ignoring legacy app-server notification in tui_app_server"); } AppServerEvent::ServerRequest(request) => { if let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = request { @@ -567,48 +530,6 @@ pub(super) fn thread_snapshot_events( .collect() } -fn legacy_thread_notification( - notification: JSONRPCNotification, -) -> Option<(ThreadId, LegacyThreadNotification)> { - let method = notification - .method - .strip_prefix("codex/event/") - .unwrap_or(¬ification.method); - - let Value::Object(mut params) = notification.params? else { - return None; - }; - let thread_id = params - .remove("conversationId") - .and_then(|value| serde_json::from_value::(value).ok()) - .and_then(|value| ThreadId::from_string(&value).ok())?; - let msg = params.get("msg").and_then(Value::as_object)?; - - match method { - "warning" => { - let message = msg - .get("type") - .and_then(Value::as_str) - .zip(msg.get("message")) - .and_then(|(kind, message)| (kind == "warning").then_some(message)) - .and_then(Value::as_str) - .map(ToOwned::to_owned)?; - Some((thread_id, LegacyThreadNotification::Warning(message))) - } - "thread_rolled_back" => { - let num_turns = msg - .get("type") - .and_then(Value::as_str) - .zip(msg.get("num_turns")) - .and_then(|(kind, num_turns)| (kind == "thread_rolled_back").then_some(num_turns)) - .and_then(Value::as_u64) - .and_then(|num_turns| u32::try_from(num_turns).ok())?; - Some((thread_id, LegacyThreadNotification::Rollback { num_turns })) - } - _ => None, - } -} - #[cfg(test)] fn server_notification_thread_events( notification: ServerNotification, @@ -1289,9 +1210,7 @@ fn app_server_codex_error_info_to_core( #[cfg(test)] mod tests { - use super::LegacyThreadNotification; use super::command_execution_started_event; - use super::legacy_thread_notification; use super::server_notification_thread_events; use super::thread_snapshot_events; use super::turn_snapshot_events; @@ -1303,7 +1222,6 @@ mod tests { use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; - use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::Thread; @@ -1324,57 +1242,8 @@ mod tests { use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; use pretty_assertions::assert_eq; - use serde_json::json; use std::path::PathBuf; - #[test] - fn legacy_warning_notification_extracts_thread_id_and_message() { - let thread_id = ThreadId::new(); - let warning = legacy_thread_notification(JSONRPCNotification { - method: "codex/event/warning".to_string(), - params: Some(json!({ - "conversationId": thread_id.to_string(), - "id": "event-1", - "msg": { - "type": "warning", - "message": "legacy warning message", - }, - })), - }); - - assert_eq!( - warning, - Some(( - thread_id, - LegacyThreadNotification::Warning("legacy warning message".to_string()) - )) - ); - } - - #[test] - fn legacy_thread_rollback_notification_extracts_thread_id_and_turn_count() { - let thread_id = ThreadId::new(); - let rollback = legacy_thread_notification(JSONRPCNotification { - method: "codex/event/thread_rolled_back".to_string(), - params: Some(json!({ - "conversationId": thread_id.to_string(), - "id": "event-1", - "msg": { - "type": "thread_rolled_back", - "num_turns": 2, - }, - })), - }); - - assert_eq!( - rollback, - Some(( - thread_id, - LegacyThreadNotification::Rollback { num_turns: 2 } - )) - ); - } - #[test] fn bridges_completed_agent_messages_from_server_notifications() { let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 0d53ae3f7a..82cd0d99bc 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -9548,10 +9548,6 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn add_warning_message(&mut self, message: String) { - self.on_warning(message); - } - fn add_app_server_stub_message(&mut self, feature: &str) { warn!(feature, "stubbed unsupported app-server TUI feature"); self.add_error_message(format!("{feature}: {APP_SERVER_TUI_STUB_MESSAGE}")); From c23566b3aff26abed2893517ba97d03fb7890943 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sat, 21 Mar 2026 13:43:14 -0700 Subject: [PATCH 05/38] Add JIT entitlement for macosx (#15409) Without this entitlement, hardened mac os release binaries are unable to allocate the executable memory for the JIT compiled JS. Tested with local signing. Without entitlement I reproduce the error: ``` # # Fatal process out of memory: Failed to reserve virtual memory for CodeRange # ==== C stack trace =============================== 0 codex 0x00000001075d1acc codex + 85760716 1 codex 0x00000001075d6a64 codex + 85781092 2 codex 0x00000001075c7100 codex + 85717248 3 codex 0x0000000107637394 codex + 86176660 4 codex 0x0000000107823cfc codex + 88194300 5 codex 0x000000010777c438 codex + 87508024 6 codex 0x000000010777d130 codex + 87511344 7 codex 0x0000000107c87a54 codex + 92797524 8 codex 0x0000000107641188 codex + 86217096 9 codex 0x00000001076412d8 codex + 86217432 10 codex 0x0000000107553908 codex + 85244168 11 codex 0x000000010465f124 codex + 36008228 12 codex 0x000000010466a0d0 codex + 36053200 13 codex 0x000000010466ce78 codex + 36064888 14 codex 0x000000010734edb0 codex + 83127728 15 libsystem_pthread.dylib 0x00000001810d3c08 _pthread_start + 136 16 libsystem_pthread.dylib 0x00000001810ceba8 thread_start + 8 zsh: trace trap target/release/codex exec --enable code_mode_only --enable code_mode -- ``` With the entitlement the exec succeeds. --- .github/actions/macos-code-sign/action.yml | 4 +++- .github/actions/macos-code-sign/codex.entitlements.plist | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .github/actions/macos-code-sign/codex.entitlements.plist diff --git a/.github/actions/macos-code-sign/action.yml b/.github/actions/macos-code-sign/action.yml index ea4a19a8f5..200b23901f 100644 --- a/.github/actions/macos-code-sign/action.yml +++ b/.github/actions/macos-code-sign/action.yml @@ -132,9 +132,11 @@ runs: keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}") fi + entitlements_path="$GITHUB_ACTION_PATH/codex.entitlements.plist" + for binary in codex codex-responses-api-proxy; do path="codex-rs/target/${TARGET}/release/${binary}" - codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" + codesign --force --options runtime --timestamp --entitlements "$entitlements_path" --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" done - name: Notarize macOS binaries diff --git a/.github/actions/macos-code-sign/codex.entitlements.plist b/.github/actions/macos-code-sign/codex.entitlements.plist new file mode 100644 index 0000000000..d35e43ae58 --- /dev/null +++ b/.github/actions/macos-code-sign/codex.entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + From cf0223887fb84a9bd57986c21f3d7eadfce249a3 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 21 Mar 2026 15:06:10 -0600 Subject: [PATCH 06/38] Remove legacy auth and notification handling from tui_app_server (#15414) ## Summary - remove `tui_app_server` handling for legacy app-server notifications - drop the local ChatGPT auth refresh request path from `tui_app_server` - remove the now-unused refresh response helper from local auth loading Split out of #15106 so the `tui_app_server` cleanup can land separately from the larger `codex-exec` app-server migration. --- .../src/app/app_server_adapter.rs | 206 ------------------ .../tui_app_server/src/local_chatgpt_auth.rs | 11 - 2 files changed, 217 deletions(-) diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 26aff5ae3b..952a9bf7b8 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -16,12 +16,9 @@ use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; use crate::app_server_session::app_server_rate_limit_snapshot_to_core; use crate::app_server_session::status_account_display_from_auth_mode; -use crate::local_chatgpt_auth::load_local_chatgpt_auth; use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; #[cfg(test)] @@ -129,15 +126,6 @@ impl App { tracing::debug!("ignoring legacy app-server notification in tui_app_server"); } AppServerEvent::ServerRequest(request) => { - if let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = request { - self.handle_chatgpt_auth_tokens_refresh_request( - app_server_client, - request_id, - params, - ) - .await; - return; - } self.handle_server_request_event(app_server_client, request) .await; } @@ -256,71 +244,6 @@ impl App { tracing::warn!("failed to enqueue app-server request: {err}"); } } - - async fn handle_chatgpt_auth_tokens_refresh_request( - &mut self, - app_server_client: &AppServerSession, - request_id: RequestId, - params: ChatgptAuthTokensRefreshParams, - ) { - let config = self.config.clone(); - let result = tokio::task::spawn_blocking(move || { - resolve_chatgpt_auth_tokens_refresh_response( - &config.codex_home, - config.cli_auth_credentials_store_mode, - config.forced_chatgpt_workspace_id.as_deref(), - ¶ms, - ) - }) - .await; - - match result { - Ok(Ok(response)) => { - let response = serde_json::to_value(response).map_err(|err| { - format!("failed to serialize chatgpt auth refresh response: {err}") - }); - match response { - Ok(response) => { - if let Err(err) = app_server_client - .resolve_server_request(request_id, response) - .await - { - tracing::warn!("failed to resolve chatgpt auth refresh request: {err}"); - } - } - Err(err) => { - self.chat_widget.add_error_message(err.clone()); - if let Err(reject_err) = self - .reject_app_server_request(app_server_client, request_id, err) - .await - { - tracing::warn!("{reject_err}"); - } - } - } - } - Ok(Err(err)) => { - self.chat_widget.add_error_message(err.clone()); - if let Err(reject_err) = self - .reject_app_server_request(app_server_client, request_id, err) - .await - { - tracing::warn!("{reject_err}"); - } - } - Err(err) => { - let message = format!("chatgpt auth refresh task failed: {err}"); - self.chat_widget.add_error_message(message.clone()); - if let Err(reject_err) = self - .reject_app_server_request(app_server_client, request_id, message) - .await - { - tracing::warn!("{reject_err}"); - } - } - } - } - async fn reject_app_server_request( &self, app_server_client: &AppServerSession, @@ -482,28 +405,6 @@ fn server_notification_thread_target( } } -fn resolve_chatgpt_auth_tokens_refresh_response( - codex_home: &std::path::Path, - auth_credentials_store_mode: codex_core::auth::AuthCredentialsStoreMode, - forced_chatgpt_workspace_id: Option<&str>, - params: &ChatgptAuthTokensRefreshParams, -) -> Result { - let auth = load_local_chatgpt_auth( - codex_home, - auth_credentials_store_mode, - forced_chatgpt_workspace_id, - )?; - if let Some(previous_account_id) = params.previous_account_id.as_deref() - && previous_account_id != auth.chatgpt_account_id - { - return Err(format!( - "local ChatGPT auth refresh account mismatch: expected `{previous_account_id}`, got `{}`", - auth.chatgpt_account_id - )); - } - Ok(auth.to_refresh_response()) -} - #[cfg(test)] /// Convert a `Thread` snapshot into a flat sequence of protocol `Event`s /// suitable for replaying into the TUI event store. @@ -1074,113 +975,6 @@ fn split_command_string(command: &str) -> Vec { } } -#[cfg(test)] -mod refresh_tests { - use super::*; - - use base64::Engine; - use chrono::Utc; - use codex_app_server_protocol::AuthMode; - use codex_core::auth::AuthCredentialsStoreMode; - use codex_core::auth::AuthDotJson; - use codex_core::auth::save_auth; - use codex_core::token_data::TokenData; - use pretty_assertions::assert_eq; - use serde::Serialize; - use serde_json::json; - use tempfile::TempDir; - - fn fake_jwt(account_id: &str, plan_type: &str) -> String { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = json!({ - "email": "user@example.com", - "https://api.openai.com/auth": { - "chatgpt_account_id": account_id, - "chatgpt_plan_type": plan_type, - }, - }); - let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); - let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header")); - let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload")); - let signature_b64 = encode(b"sig"); - format!("{header_b64}.{payload_b64}.{signature_b64}") - } - - fn write_chatgpt_auth(codex_home: &std::path::Path) { - let id_token = fake_jwt("workspace-1", "business"); - let access_token = fake_jwt("workspace-1", "business"); - save_auth( - codex_home, - &AuthDotJson { - auth_mode: Some(AuthMode::Chatgpt), - openai_api_key: None, - tokens: Some(TokenData { - id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token) - .expect("id token should parse"), - access_token, - refresh_token: "refresh-token".to_string(), - account_id: Some("workspace-1".to_string()), - }), - last_refresh: Some(Utc::now()), - }, - AuthCredentialsStoreMode::File, - ) - .expect("chatgpt auth should save"); - } - - #[test] - fn refresh_request_uses_local_chatgpt_auth() { - let codex_home = TempDir::new().expect("tempdir"); - write_chatgpt_auth(codex_home.path()); - - let response = resolve_chatgpt_auth_tokens_refresh_response( - codex_home.path(), - AuthCredentialsStoreMode::File, - Some("workspace-1"), - &ChatgptAuthTokensRefreshParams { - reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, - previous_account_id: Some("workspace-1".to_string()), - }, - ) - .expect("refresh response should resolve"); - - assert_eq!(response.chatgpt_account_id, "workspace-1"); - assert_eq!(response.chatgpt_plan_type.as_deref(), Some("business")); - assert!(!response.access_token.is_empty()); - } - - #[test] - fn refresh_request_rejects_account_mismatch() { - let codex_home = TempDir::new().expect("tempdir"); - write_chatgpt_auth(codex_home.path()); - - let err = resolve_chatgpt_auth_tokens_refresh_response( - codex_home.path(), - AuthCredentialsStoreMode::File, - Some("workspace-1"), - &ChatgptAuthTokensRefreshParams { - reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, - previous_account_id: Some("workspace-2".to_string()), - }, - ) - .expect_err("mismatched account should fail"); - - assert_eq!( - err, - "local ChatGPT auth refresh account mismatch: expected `workspace-2`, got `workspace-1`" - ); - } -} - #[cfg(test)] fn app_server_web_search_action_to_core( action: codex_app_server_protocol::WebSearchAction, diff --git a/codex-rs/tui_app_server/src/local_chatgpt_auth.rs b/codex-rs/tui_app_server/src/local_chatgpt_auth.rs index 6fbed6cc79..33d6e508a9 100644 --- a/codex-rs/tui_app_server/src/local_chatgpt_auth.rs +++ b/codex-rs/tui_app_server/src/local_chatgpt_auth.rs @@ -1,7 +1,6 @@ use std::path::Path; use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::load_auth_dot_json; @@ -12,16 +11,6 @@ pub(crate) struct LocalChatgptAuth { pub(crate) chatgpt_plan_type: Option, } -impl LocalChatgptAuth { - pub(crate) fn to_refresh_response(&self) -> ChatgptAuthTokensRefreshResponse { - ChatgptAuthTokensRefreshResponse { - access_token: self.access_token.clone(), - chatgpt_account_id: self.chatgpt_account_id.clone(), - chatgpt_plan_type: self.chatgpt_plan_type.clone(), - } - } -} - pub(crate) fn load_local_chatgpt_auth( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, From 19702e190ebf16f789617ca5f16bfc373c238fe7 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Sun, 22 Mar 2026 00:17:48 -0700 Subject: [PATCH 07/38] [apps] Improve app tools loading for TUI. (#15376) - [x] Remove the app tools copy in TUI and reference the core tools instead, this reduces tools/list calls from 4 to just 1. --- codex-rs/tui/src/chatwidget.rs | 99 +++++++++++++++++++++++----- codex-rs/tui/src/chatwidget/tests.rs | 1 + 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c8d9e3b61b..3729778b1b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -720,6 +720,7 @@ pub(crate) struct ChatWidget { connectors_partial_snapshot: Option, connectors_prefetch_in_flight: bool, connectors_force_refetch_pending: bool, + pending_mcp_output_requests: usize, plugins_cache: PluginsCacheState, plugins_fetch_state: PluginListFetchState, // Queue of interruptive UI events deferred during an active write cycle @@ -1456,9 +1457,6 @@ impl ChatWidget { cwds: Vec::new(), force_reload: true, }); - if self.connectors_enabled() { - self.prefetch_connectors(); - } if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -2184,6 +2182,7 @@ impl ChatWidget { } fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { + let codex_apps_ready = ev.ready.iter().any(|server| server == "codex_apps"); let mut parts = Vec::new(); if !ev.failed.is_empty() { let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect(); @@ -2202,6 +2201,11 @@ impl ChatWidget { self.mcp_startup_status = None; self.update_task_running_state(); self.maybe_send_next_queued_input(); + if self.connectors_enabled() && codex_apps_ready { + // Populate `$` app mentions from the session's already-started MCP manager + // instead of doing a separate TUI-side connector prefetch. + self.submit_op(Op::ListMcpTools); + } self.request_redraw(); } @@ -3665,6 +3669,7 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), interrupts: InterruptManager::new(), @@ -3866,6 +3871,7 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), interrupts: InterruptManager::new(), @@ -4059,6 +4065,7 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), interrupts: InterruptManager::new(), @@ -5943,10 +5950,6 @@ impl ChatWidget { self.prefetch_connectors_with_options(force_refetch); } - fn prefetch_connectors(&mut self) { - self.prefetch_connectors_with_options(/*force_refetch*/ false); - } - fn prefetch_connectors_with_options(&mut self, force_refetch: bool) { if !self.connectors_enabled() { return; @@ -8368,8 +8371,8 @@ impl ChatWidget { .is_empty() { self.add_to_history(history_cell::empty_mcp_output()); - } else { - self.submit_op(Op::ListMcpTools); + } else if self.submit_op(Op::ListMcpTools) { + self.pending_mcp_output_requests = self.pending_mcp_output_requests.saturating_add(1); } } @@ -8831,13 +8834,77 @@ impl ChatWidget { } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(history_cell::new_mcp_tools_output( - &self.config, - ev.tools, - ev.resources, - ev.resource_templates, - &ev.auth_statuses, - )); + if self.connectors_enabled() { + let mut connectors_by_id: HashMap = HashMap::new(); + for tool in ev.tools.values() { + let Some(meta) = tool.meta.as_ref().and_then(serde_json::Value::as_object) else { + continue; + }; + let Some(connector_id) = meta + .get("connector_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + else { + continue; + }; + connectors_by_id + .entry(connector_id.to_string()) + .or_insert_with(|| { + let name = meta + .get("connector_name") + .or_else(|| meta.get("connector_display_name")) + .and_then(serde_json::Value::as_str) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(connector_id) + .to_string(); + let description = meta + .get("connector_description") + .or_else(|| meta.get("connectorDescription")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(ToString::to_string); + connectors::AppInfo { + id: connector_id.to_string(), + name, + description, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + } + }); + } + + let mut app_connectors = connectors_by_id.into_values().collect::>(); + app_connectors.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + let app_connectors = connectors::with_app_enabled_state(app_connectors, &self.config); + self.bottom_pane + .set_connectors_snapshot(Some(ConnectorsSnapshot { + connectors: app_connectors, + })); + } + + if self.pending_mcp_output_requests > 0 { + self.pending_mcp_output_requests -= 1; + self.add_to_history(history_cell::new_mcp_tools_output( + &self.config, + ev.tools, + ev.resources, + ev.resource_templates, + &ev.auth_statuses, + )); + } } fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a614d1361d..d837bb8ab7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1895,6 +1895,7 @@ async fn make_chatwidget_manual( connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), interrupts: InterruptManager::new(), From 31728dd460ac0902aa1039972fb642a3661e112e Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sun, 22 Mar 2026 08:07:43 -0700 Subject: [PATCH 08/38] chore(exec_policy) ExecPolicyRequirementScenario tests (#15415) ## Summary Consolidate exec_policy_tests on `ExecApprovalRequirementScenario` for consistency. ## Testing - [x] These are tests --- codex-rs/core/src/exec_policy_tests.rs | 568 ++++++++++--------------- 1 file changed, 224 insertions(+), 344 deletions(-) diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index d6ec0bd3f2..f062bcc741 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -507,39 +507,25 @@ async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> { #[tokio::test] async fn evaluates_bash_lc_inner_commands() { - let policy_src = r#" -prefix_rule(pattern=["rm"], decision="forbidden") -"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - - let forbidden_script = vec![ - "bash".to_string(), - "-lc".to_string(), - "rm -rf /some/important/folder".to_string(), - ]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &forbidden_script, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="forbidden")"#.to_string()), + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "rm -rf /some/important/folder".to_string(), + ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Forbidden { - reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string() - } - ); + }, + ExecApprovalRequirement::Forbidden { + reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string(), + }, + ) + .await; } #[test] @@ -562,163 +548,132 @@ fn commands_for_exec_policy_falls_back_for_whitespace_shell_script() { #[tokio::test] async fn evaluates_heredoc_script_against_prefix_rules() { - let policy_src = r#"prefix_rule(pattern=["python3"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); let command = vec![ "bash".to_string(), "-lc".to_string(), "python3 <<'PY'\nprint('hello')\nPY".to_string(), ]; - let requirement = ExecPolicyManager::new(policy) - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["python3"], decision="allow")"#.to_string()), + command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: true, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] async fn omits_auto_amendment_for_heredoc_fallback_prompts() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "python3 <<'PY'\nprint('hello')\nPY".to_string(), - ]; - - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_match() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "python3 <<'PY'\nprint('hello')\nPY".to_string(), - ]; - let requested_prefix = vec!["python3".to_string(), "-m".to_string(), "pip".to_string()]; - - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: Some(requested_prefix.clone()), - }) - .await; - - assert_eq!( - requirement, + prefix_rule: Some(vec![ + "python3".to_string(), + "-m".to_string(), + "pip".to_string(), + ]), + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] async fn justification_is_included_in_forbidden_exec_approval_requirement() { - let policy_src = r#" + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some( + r#" prefix_rule( pattern=["rm"], decision="forbidden", justification="destructive command", ) -"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &[ +"# + .to_string(), + ), + command: vec![ "rm".to_string(), "-rf".to_string(), "/some/important/folder".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Forbidden { - reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string() - } - ); + reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string(), + }, + ) + .await; } #[tokio::test] async fn exec_approval_requirement_prefers_execpolicy_match() { - let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["rm".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()), + command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: Some("`rm` requires approval by policy".to_string()), proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] @@ -731,31 +686,22 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) prefix_rule(pattern=["git"], decision="allow") "# ); - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", &policy_src) - .expect("parse policy"); - let manager = ExecPolicyManager::new(Arc::new(parser.build())); - let command = vec![git_path, "status".to_string()]; - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(policy_src), + command: vec![git_path, "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: true, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] @@ -774,93 +720,71 @@ host_executable(name = "git", paths = ["{allowed_git_path_literal}"]) prefix_rule(pattern=["git"], decision="prompt") "# ); - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", &policy_src) - .expect("parse policy"); - let manager = ExecPolicyManager::new(Arc::new(parser.build())); - let command = vec![disallowed_git_path, "status".to_string()]; - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(policy_src), + command: vec![disallowed_git_path.clone(), "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: false, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + disallowed_git_path, + "status".to_string(), + ])), + }, + ) + .await; } #[tokio::test] async fn requested_prefix_rule_can_approve_absolute_path_commands() { - let command = vec![ - host_program_path("cargo"), - "install".to_string(), - "cargo-insta".to_string(), - ]; - let manager = ExecPolicyManager::default(); - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + host_program_path("cargo"), + "install".to_string(), + "cargo-insta".to_string(), + ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, 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![ "cargo".to_string(), "install".to_string(), ])), - } - ); + }, + ) + .await; } #[tokio::test] async fn exec_approval_requirement_respects_approval_policy() { - let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["rm".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()), + command: vec!["rm".to_string()], approval_policy: AskForApproval::Never, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Forbidden { - reason: PROMPT_CONFLICT_REASON.to_string() - } - ); + reason: PROMPT_CONFLICT_REASON.to_string(), + }, + ) + .await; } #[test] @@ -907,11 +831,10 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { #[tokio::test] async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_granular_sandbox_is_disabled() { - let command = vec!["madeup-cmd".to_string()]; - - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec!["madeup-cmd".to_string()], approval_policy: AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: false, rules: true, @@ -919,19 +842,16 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra request_permissions: true, mcp_elicitations: true, }), - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Forbidden { reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(), - } - ); + }, + ) + .await; } #[tokio::test] @@ -1243,178 +1163,140 @@ async fn append_execpolicy_amendment_rejects_empty_prefix() { async fn proposed_execpolicy_amendment_is_present_for_single_command_without_policy_match() { let command = vec!["cargo".to_string(), "build".to_string()]; - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: command.clone(), approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)) - } - ); + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { - let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["rm".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()), + command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: Some("`rm` requires approval by policy".to_string()), proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "cargo build && echo ok".to_string(), - ]; - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "cargo build && echo ok".to_string(), + ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "cargo".to_string(), - "build".to_string() + "build".to_string(), ])), - } - ); + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scripts() { let policy_src = r#"prefix_rule(pattern=["cat"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec![ "bash".to_string(), "-lc".to_string(), "cat && apple".to_string(), ]; - assert_eq!( - ExecPolicyManager::new(policy) - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(policy_src.to_string()), + command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "apple".to_string() + "apple".to_string(), ])), - } - ); + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { let command = vec!["echo".to_string(), "safe".to_string()]; - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: command.clone(), approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: false, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() { - let policy_src = r#"prefix_rule(pattern=["echo"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["echo".to_string(), "safe".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()), + command: vec!["echo".to_string(), "safe".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: true, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } fn derive_requested_execpolicy_amendment_for_test( @@ -1564,25 +1446,23 @@ fn derive_requested_execpolicy_amendment_returns_none_when_policy_matches() { #[tokio::test] async fn dangerous_rm_rf_requires_approval_in_danger_full_access() { let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]); - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: command.clone(), approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); + }, + ) + .await; } fn vec_str(items: &[&str]) -> Vec { From e830000e4130b932f1ba286974f5d9a414858a9c Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Sun, 22 Mar 2026 17:10:42 -0700 Subject: [PATCH 09/38] Remove smart_approvals alias migration (#15464) Remove the legacy `smart_approvals` config migration from core config loading. This change: - stops rewriting `smart_approvals` into `guardian_approval` - stops backfilling `approvals_reviewer = "guardian_subagent"` - replaces the migration tests with regression coverage that asserts the deprecated key is ignored in root and profile scopes Verification: - `just fmt` - `cargo test -p codex-core smart_approvals_alias_is_ignored` - `cargo test -p codex-core approvals_reviewer_` - `just argument-comment-lint` Notes: - `cargo test -p codex-core` still hits an unrelated existing failure in `tools::js_repl::tests::js_repl_imported_local_files_can_access_repl_globals`; the JS REPL kernel exits after `mktemp` fails under the current environment. Enhancement request: requested cleanup to delete the `smart_approvals` alias migration; no public issue link is available. Co-authored-by: Codex --- codex-rs/core/src/config/config_tests.rs | 125 ++--------------------- codex-rs/core/src/config/mod.rs | 112 -------------------- 2 files changed, 11 insertions(+), 226 deletions(-) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 1c78759ec4..65c05468e0 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5677,7 +5677,7 @@ approvals_reviewer = "guardian_subagent" } #[tokio::test] -async fn smart_approvals_alias_is_migrated_to_guardian_approval() -> std::io::Result<()> { +async fn smart_approvals_alias_is_ignored() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -5692,23 +5692,19 @@ smart_approvals = true .build() .await?; - assert!(config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.features.legacy_feature_usages().count(), 0); - assert_eq!( - config.approvals_reviewer, - ApprovalsReviewer::GuardianSubagent - ); + assert!(!config.features.enabled(Feature::GuardianApproval)); + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("guardian_approval = true")); - assert!(serialized.contains("approvals_reviewer = \"guardian_subagent\"")); - assert!(!serialized.contains("smart_approvals")); + assert!(serialized.contains("smart_approvals = true")); + assert!(!serialized.contains("guardian_approval")); + assert!(!serialized.contains("approvals_reviewer")); Ok(()) } #[tokio::test] -async fn smart_approvals_alias_is_migrated_in_profiles() -> std::io::Result<()> { +async fn smart_approvals_alias_is_ignored_in_profiles() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -5719,106 +5715,6 @@ smart_approvals = true "#, )?; - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert!(config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.features.legacy_feature_usages().count(), 0); - assert_eq!( - config.approvals_reviewer, - ApprovalsReviewer::GuardianSubagent - ); - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("[profiles.guardian.features]")); - assert!(serialized.contains("guardian_approval = true")); - assert!(serialized.contains("approvals_reviewer = \"guardian_subagent\"")); - assert!(!serialized.contains("smart_approvals")); - - Ok(()) -} - -#[tokio::test] -async fn smart_approvals_alias_migration_preserves_disabled_profile_override() -> std::io::Result<()> -{ - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"[features] -guardian_approval = true - -[profiles.guardian.features] -smart_approvals = false -"#, - )?; - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .harness_overrides(ConfigOverrides { - config_profile: Some("guardian".to_string()), - ..Default::default() - }) - .build() - .await?; - - assert!(!config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.features.legacy_feature_usages().count(), 0); - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("[profiles.guardian.features]")); - assert!(serialized.contains("guardian_approval = false")); - assert!(!serialized.contains("smart_approvals")); - - Ok(()) -} - -#[tokio::test] -async fn smart_approvals_alias_migration_preserves_existing_approvals_reviewer() --> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"approvals_reviewer = "user" - -[features] -smart_approvals = true -"#, - )?; - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert!(config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("guardian_approval = true")); - assert!(serialized.contains("approvals_reviewer = \"user\"")); - assert!(!serialized.contains("smart_approvals")); - - Ok(()) -} - -#[tokio::test] -async fn smart_approvals_alias_migration_does_not_override_canonical_disabled_flag() --> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"[features] -guardian_approval = false -smart_approvals = true -"#, - )?; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) @@ -5829,9 +5725,10 @@ smart_approvals = true assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("guardian_approval = false")); - assert!(!serialized.contains("approvals_reviewer = \"guardian_subagent\"")); - assert!(!serialized.contains("smart_approvals")); + assert!(serialized.contains("[profiles.guardian.features]")); + assert!(serialized.contains("smart_approvals = true")); + assert!(!serialized.contains("guardian_approval")); + assert!(!serialized.contains("approvals_reviewer")); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 17abe55c46..6df989db16 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -104,7 +104,6 @@ use crate::config::profile::ConfigProfile; use codex_network_proxy::NetworkProxyConfig; use toml::Value as TomlValue; use toml_edit::DocumentMut; -use toml_edit::value; pub(crate) mod agent_roles; pub mod edit; @@ -652,9 +651,6 @@ impl ConfigBuilder { fallback_cwd, } = self; let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?; - if let Err(err) = maybe_migrate_smart_approvals_alias(&codex_home).await { - tracing::warn!(error = %err, "failed to migrate smart_approvals feature alias"); - } let cli_overrides = cli_overrides.unwrap_or_default(); let mut harness_overrides = harness_overrides.unwrap_or_default(); let loader_overrides = loader_overrides.unwrap_or_default(); @@ -702,111 +698,6 @@ impl ConfigBuilder { } } -fn config_scope_segments(scope: &[String], key: &str) -> Vec { - let mut segments = scope.to_vec(); - segments.push(key.to_string()); - segments -} - -fn feature_scope_segments(scope: &[String], feature_key: &str) -> Vec { - let mut segments = scope.to_vec(); - segments.push("features".to_string()); - segments.push(feature_key.to_string()); - segments -} - -fn push_smart_approvals_alias_migration_edits( - edits: &mut Vec, - scope: &[String], - features: &FeaturesToml, - approvals_reviewer_missing: bool, -) { - let Some(alias_enabled) = features.entries.get("smart_approvals").copied() else { - return; - }; - let canonical_enabled = features - .entries - .get("guardian_approval") - .copied() - .unwrap_or(alias_enabled); - - if !features.entries.contains_key("guardian_approval") { - edits.push(ConfigEdit::SetPath { - segments: feature_scope_segments(scope, "guardian_approval"), - value: value(alias_enabled), - }); - } - if canonical_enabled && approvals_reviewer_missing { - edits.push(ConfigEdit::SetPath { - segments: config_scope_segments(scope, "approvals_reviewer"), - value: value(ApprovalsReviewer::GuardianSubagent.to_string()), - }); - } - edits.push(ConfigEdit::ClearPath { - segments: feature_scope_segments(scope, "smart_approvals"), - }); -} - -/// Rewrites the legacy `smart_approvals` feature flag to -/// `guardian_approval` in `config.toml` before normal config loading. -/// -/// If the old key is present, this preserves its value by setting -/// `guardian_approval = ` when the new key is not already present. -/// Because the deprecated flag historically meant "turn guardian review on", -/// this migration also backfills `approvals_reviewer = "guardian_subagent"` -/// in the same scope when that reviewer is not already configured there and the -/// migrated feature value is `true`. -/// In all cases it removes the deprecated `smart_approvals` entry so future -/// loads only see the canonical feature flag name. -async fn maybe_migrate_smart_approvals_alias(codex_home: &Path) -> std::io::Result { - let config_path = codex_home.join(CONFIG_TOML_FILE); - if !tokio::fs::try_exists(&config_path).await? { - return Ok(false); - } - - let config_contents = tokio::fs::read_to_string(&config_path).await?; - let Ok(config_toml) = toml::from_str::(&config_contents) else { - return Ok(false); - }; - - let mut edits = Vec::new(); - - let root_scope = Vec::new(); - if let Some(features) = config_toml.features.as_ref() { - push_smart_approvals_alias_migration_edits( - &mut edits, - &root_scope, - features, - config_toml.approvals_reviewer.is_none(), - ); - } - - for (profile_name, profile) in &config_toml.profiles { - if let Some(features) = profile.features.as_ref() { - let scope = vec!["profiles".to_string(), profile_name.clone()]; - push_smart_approvals_alias_migration_edits( - &mut edits, - &scope, - features, - profile.approvals_reviewer.is_none(), - ); - } - } - - if edits.is_empty() { - return Ok(false); - } - - ConfigEditsBuilder::new(codex_home) - .with_edits(edits) - .apply() - .await - .map_err(|err| { - std::io::Error::other(format!("failed to migrate guardian_approval alias: {err}")) - })?; - Ok(true) -} - impl Config { /// This is the preferred way to create an instance of [Config]. pub async fn load_with_cli_overrides( @@ -868,9 +759,6 @@ pub async fn load_config_as_toml_with_cli_overrides( cwd: &AbsolutePathBuf, cli_overrides: Vec<(String, TomlValue)>, ) -> std::io::Result { - if let Err(err) = maybe_migrate_smart_approvals_alias(codex_home).await { - tracing::warn!(error = %err, "failed to migrate smart_approvals feature alias"); - } let config_layer_stack = load_config_layers_state( codex_home, Some(cwd.clone()), From 85065ea1b83d86220c905b00ca9c9597f623957e Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Sun, 22 Mar 2026 18:24:14 -0700 Subject: [PATCH 10/38] core: snapshot fork startup context injection (#15443) ## Summary - add a snapshot-style core test for fork startup context injection followed by first-turn diff injection - capture the current duplicated startup-plus-turn context behavior without changing runtime logic ## Testing - not run locally; relying on CI - just fmt --------- Co-authored-by: Codex --- codex-rs/core/src/codex_tests.rs | 119 ++++++++++++++++++ ..._startup_context_then_first_turn_diff.snap | 17 +++ 2 files changed, 136 insertions(+) create mode 100644 codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index c7a715cd93..e2ad0982bc 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -64,17 +64,31 @@ use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; use codex_network_proxy::NetworkProxyConfig; use codex_otel::TelemetryAuthMode; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::Submission; use codex_protocol::protocol::W3cTraceContext; +use core_test_support::context_snapshot; +use core_test_support::context_snapshot::ContextSnapshotOptions; +use core_test_support::context_snapshot::ContextSnapshotRenderMode; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; use core_test_support::tracing::install_test_tracing; +use core_test_support::wait_for_event; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceId; use std::path::Path; @@ -1115,6 +1129,111 @@ async fn record_initial_history_reconstructs_forked_transcript() { assert_eq!(expected, history.raw_items()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> { + let server = start_mock_server().await; + mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let first_forked_request = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config.permissions.approval_policy = + codex_config::Constrained::allow_any(AskForApproval::OnRequest); + }); + let initial = builder.build(&server).await?; + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + initial + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "fork seed".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let mut fork_config = initial.config.clone(); + fork_config.permissions.approval_policy = + codex_config::Constrained::allow_any(AskForApproval::UnlessTrusted); + let forked = initial + .thread_manager + .fork_thread(usize::MAX, fork_config, rollout_path, false, None) + .await?; + + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: forked.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: Some("Fork turn collaboration instructions.".to_string()), + }, + }; + forked + .thread + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(AskForApproval::Never), + approvals_reviewer: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: Some(collaboration_mode), + personality: None, + }) + .await?; + + forked + .thread + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "after fork".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = first_forked_request.single_request(); + let snapshot = context_snapshot::format_labeled_requests_snapshot( + "First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.", + &[("First Forked Turn Request", &request)], + &ContextSnapshotOptions::default() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }) + .strip_capability_instructions() + .strip_agents_md_user_context(), + ); + + let mut settings = insta::Settings::clone_current(); + settings.set_snapshot_path("snapshots"); + settings.set_prepend_module_to_snapshot(false); + settings.bind(|| { + insta::assert_snapshot!( + "codex_core__codex_tests__fork_startup_context_then_first_turn_diff", + snapshot + ); + }); + + Ok(()) +} + #[tokio::test] async fn record_initial_history_forked_hydrates_previous_turn_settings() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap b/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap new file mode 100644 index 0000000000..90bb82d409 --- /dev/null +++ b/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap @@ -0,0 +1,17 @@ +--- +source: core/src/codex_tests.rs +assertion_line: 1282 +expression: snapshot +--- +Scenario: First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode. + +## First Forked Turn Request +00:message/developer: +01:message/user:> +02:message/user:fork seed +03:message/developer: +04:message/user:> +05:message/developer[2]: + [01] + [02] Fork turn collaboration instructions. +06:message/user:after fork From 5e3793def286099deaf5a6ae625e1f31ad584790 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Sun, 22 Mar 2026 21:19:31 -0700 Subject: [PATCH 11/38] Use Shift+Left to edit queued messages in tmux (#15480) ## Summary - use Shift+Left to edit the most recent queued message when running under tmux - mirror the same binding change in the app-server TUI - add tmux-specific tests and snapshot coverage for the rendered queued-message hint ## Testing - just fmt - cargo test -p codex-tui - cargo test -p codex-tui-app-server - just argument-comment-lint -p codex-tui -p codex-tui-app-server Co-authored-by: Codex --- .../src/bottom_pane/pending_input_preview.rs | 15 +++ ...r_one_message_with_shift_left_binding.snap | 21 +++++ codex-rs/tui/src/chatwidget.rs | 28 +++--- codex-rs/tui/src/chatwidget/tests.rs | 91 ++++++++++++++++--- .../src/bottom_pane/pending_input_preview.rs | 15 +++ ...r_one_message_with_shift_left_binding.snap | 21 +++++ codex-rs/tui_app_server/src/chatwidget.rs | 22 +++-- .../tui_app_server/src/chatwidget/tests.rs | 91 ++++++++++++++++--- 8 files changed, 262 insertions(+), 42 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap create mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 315e311e02..1f38a17773 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -176,6 +176,21 @@ mod tests { assert_snapshot!("render_one_message", format!("{buf:?}")); } + #[test] + fn render_one_message_with_shift_left_binding() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue.set_edit_binding(key_hint::shift(KeyCode::Left)); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_message_with_shift_left_binding", + format!("{buf:?}") + ); + } + #[test] fn render_two_messages() { let mut queue = PendingInputPreview::new(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap new file mode 100644 index 0000000000..f3ad37e731 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + "• Queued follow-up messages ", + " ↳ Hello, world! ", + " shift + ← edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3729778b1b..4a6ef92865 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -155,6 +155,8 @@ use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_sleep_inhibitor::SleepInhibitor; @@ -195,14 +197,21 @@ 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. +/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall +/// back to Shift+Left for those environments 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 { +fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding { + if matches!( + terminal_info.multiplexer.as_ref(), + Some(Multiplexer::Tmux { .. }) + ) { + return key_hint::shift(KeyCode::Left); + } + + match terminal_info.name { TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { key_hint::shift(KeyCode::Left) } @@ -3617,8 +3626,7 @@ 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 queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -3819,8 +3827,7 @@ 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 queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -4013,8 +4020,7 @@ impl ChatWidget { settings: fallback_default, }; - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d837bb8ab7..ed37fc5e72 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -120,6 +120,8 @@ use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::builtin_approval_presets; @@ -3756,10 +3758,10 @@ async fn alt_up_edits_most_recent_queued_message() { } async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( - terminal_name: TerminalName, + terminal_info: TerminalInfo, ) { 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.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info); chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_binding); @@ -3791,37 +3793,102 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( #[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; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .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; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .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; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_tmux() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }) + .await; } #[test] -fn queued_message_edit_binding_mapping_covers_special_terminals() { +fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() { assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::VsCode), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::Iterm2), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::alt(KeyCode::Up) ); } diff --git a/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs index 315e311e02..1f38a17773 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs @@ -176,6 +176,21 @@ mod tests { assert_snapshot!("render_one_message", format!("{buf:?}")); } + #[test] + fn render_one_message_with_shift_left_binding() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue.set_edit_binding(key_hint::shift(KeyCode::Left)); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_message_with_shift_left_binding", + format!("{buf:?}") + ); + } + #[test] fn render_two_messages() { let mut queue = PendingInputPreview::new(); diff --git a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap new file mode 100644 index 0000000000..d70db56644 --- /dev/null +++ b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap @@ -0,0 +1,21 @@ +--- +source: tui_app_server/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + "• Queued follow-up messages ", + " ↳ Hello, world! ", + " shift + ← edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 82cd0d99bc..78fcc84e5e 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -199,6 +199,8 @@ use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_sleep_inhibitor::SleepInhibitor; @@ -238,14 +240,21 @@ const APP_SERVER_TUI_STUB_MESSAGE: &str = "Not available in app-server TUI yet." /// 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. +/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall +/// back to Shift+Left for those environments 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 { +fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding { + if matches!( + terminal_info.multiplexer.as_ref(), + Some(Multiplexer::Tmux { .. }) + ) { + return key_hint::shift(KeyCode::Left); + } + + match terminal_info.name { TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { key_hint::shift(KeyCode::Left) } @@ -4170,8 +4179,7 @@ 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 queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 639b57da09..31e94249a7 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -143,6 +143,8 @@ use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::builtin_approval_presets; @@ -3765,10 +3767,10 @@ async fn alt_up_edits_most_recent_queued_message() { } async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( - terminal_name: TerminalName, + terminal_info: TerminalInfo, ) { 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.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info); chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_binding); @@ -3800,37 +3802,102 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( #[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; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .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; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .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; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_tmux() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }) + .await; } #[test] -fn queued_message_edit_binding_mapping_covers_special_terminals() { +fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() { assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::VsCode), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::Iterm2), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::alt(KeyCode::Up) ); } From d807d44ae7fb69e8e05fc6e6fddea65f7e9421f5 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 23 Mar 2026 10:02:11 +0000 Subject: [PATCH 12/38] nit: guard -> registry (#15317) --- codex-rs/core/src/agent/control.rs | 10 +- codex-rs/core/src/agent/mod.rs | 6 +- .../core/src/agent/{guards.rs => registry.rs} | 8 +- .../{guards_tests.rs => registry_tests.rs} | 120 ++++++++++-------- 4 files changed, 78 insertions(+), 66 deletions(-) rename codex-rs/core/src/agent/{guards.rs => registry.rs} (98%) rename codex-rs/core/src/agent/{guards_tests.rs => registry_tests.rs} (70%) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 10cbd441b4..d2a7cbf9db 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1,6 +1,6 @@ use crate::agent::AgentStatus; -use crate::agent::guards::AgentMetadata; -use crate::agent::guards::Guards; +use crate::agent::registry::AgentMetadata; +use crate::agent::registry::AgentRegistry; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; @@ -80,14 +80,14 @@ fn agent_nickname_candidates( /// spawn new agents and the inter-agent communication layer. /// An `AgentControl` instance is intended to be created at most once per root thread/session /// tree. That same `AgentControl` is then shared with every sub-agent spawned from that root, -/// which keeps the guards scoped to that root thread rather than the entire `ThreadManager`. +/// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`. #[derive(Clone, Default)] pub(crate) struct AgentControl { /// Weak handle back to the global thread registry/state. /// This is `Weak` to avoid reference cycles and shadow persistence of the form /// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`. manager: Weak, - state: Arc, + state: Arc, } impl AgentControl { @@ -686,7 +686,7 @@ impl AgentControl { #[allow(clippy::too_many_arguments)] fn prepare_thread_spawn( &self, - reservation: &mut crate::agent::guards::SpawnReservation, + reservation: &mut crate::agent::registry::SpawnReservation, config: &crate::config::Config, parent_thread_id: ThreadId, depth: i32, diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 681f993a94..350962dc08 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,11 +1,11 @@ pub(crate) mod agent_resolver; pub(crate) mod control; -mod guards; +mod registry; pub(crate) mod role; pub(crate) mod status; pub(crate) use codex_protocol::protocol::AgentStatus; pub(crate) use control::AgentControl; -pub(crate) use guards::exceeds_thread_spawn_depth_limit; -pub(crate) use guards::next_thread_spawn_depth; +pub(crate) use registry::exceeds_thread_spawn_depth_limit; +pub(crate) use registry::next_thread_spawn_depth; pub(crate) use status::agent_status_from_event; diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/registry.rs similarity index 98% rename from codex-rs/core/src/agent/guards.rs rename to codex-rs/core/src/agent/registry.rs index 665c02ebfb..af545e8c97 100644 --- a/codex-rs/core/src/agent/guards.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -20,7 +20,7 @@ use std::sync::atomic::Ordering; /// This structure is shared by all agents in the same user session (because the `AgentControl` /// is). #[derive(Default)] -pub(crate) struct Guards { +pub(crate) struct AgentRegistry { active_agents: Mutex, total_count: AtomicUsize, } @@ -75,7 +75,7 @@ pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bo depth > max_depth } -impl Guards { +impl AgentRegistry { pub(crate) fn reserve_spawn_slot( self: &Arc, max_threads: Option, @@ -263,7 +263,7 @@ impl Guards { } pub(crate) struct SpawnReservation { - state: Arc, + state: Arc, active: bool, reserved_agent_nickname: Option, reserved_agent_path: Option, @@ -311,5 +311,5 @@ impl Drop for SpawnReservation { } #[cfg(test)] -#[path = "guards_tests.rs"] +#[path = "registry_tests.rs"] mod tests; diff --git a/codex-rs/core/src/agent/guards_tests.rs b/codex-rs/core/src/agent/registry_tests.rs similarity index 70% rename from codex-rs/core/src/agent/guards_tests.rs rename to codex-rs/core/src/agent/registry_tests.rs index 9da4cec848..43d91952a7 100644 --- a/codex-rs/core/src/agent/guards_tests.rs +++ b/codex-rs/core/src/agent/registry_tests.rs @@ -52,22 +52,22 @@ fn non_thread_spawn_subagents_default_to_depth_zero() { #[test] fn reservation_drop_releases_slot() { - let guards = Arc::new(Guards::default()); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); drop(reservation); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot released"); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot released"); drop(reservation); } #[test] fn commit_holds_slot_until_release() { - let guards = Arc::new(Guards::default()); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); let thread_id = ThreadId::new(); reservation.commit(agent_metadata(thread_id)); - let err = match guards.reserve_spawn_slot(Some(1)) { + let err = match registry.reserve_spawn_slot(Some(1)) { Ok(_) => panic!("limit should be enforced"), Err(err) => err, }; @@ -76,8 +76,8 @@ fn commit_holds_slot_until_release() { }; assert_eq!(max_threads, 1); - guards.release_spawned_thread(thread_id); - let reservation = guards + registry.release_spawned_thread(thread_id); + let reservation = registry .reserve_spawn_slot(Some(1)) .expect("slot released after thread removal"); drop(reservation); @@ -85,14 +85,14 @@ fn commit_holds_slot_until_release() { #[test] fn release_ignores_unknown_thread_id() { - let guards = Arc::new(Guards::default()); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); let thread_id = ThreadId::new(); reservation.commit(agent_metadata(thread_id)); - guards.release_spawned_thread(ThreadId::new()); + registry.release_spawned_thread(ThreadId::new()); - let err = match guards.reserve_spawn_slot(Some(1)) { + let err = match registry.reserve_spawn_slot(Some(1)) { Ok(_) => panic!("limit should still be enforced"), Err(err) => err, }; @@ -101,8 +101,8 @@ fn release_ignores_unknown_thread_id() { }; assert_eq!(max_threads, 1); - guards.release_spawned_thread(thread_id); - let reservation = guards + registry.release_spawned_thread(thread_id); + let reservation = registry .reserve_spawn_slot(Some(1)) .expect("slot released after real thread removal"); drop(reservation); @@ -110,20 +110,20 @@ fn release_ignores_unknown_thread_id() { #[test] fn release_is_idempotent_for_registered_threads() { - let guards = Arc::new(Guards::default()); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); let first_id = ThreadId::new(); reservation.commit(agent_metadata(first_id)); - guards.release_spawned_thread(first_id); + registry.release_spawned_thread(first_id); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot reused"); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot reused"); let second_id = ThreadId::new(); reservation.commit(agent_metadata(second_id)); - guards.release_spawned_thread(first_id); + registry.release_spawned_thread(first_id); - let err = match guards.reserve_spawn_slot(Some(1)) { + let err = match registry.reserve_spawn_slot(Some(1)) { Ok(_) => panic!("limit should still be enforced"), Err(err) => err, }; @@ -132,8 +132,8 @@ fn release_is_idempotent_for_registered_threads() { }; assert_eq!(max_threads, 1); - guards.release_spawned_thread(second_id); - let reservation = guards + registry.release_spawned_thread(second_id); + let reservation = registry .reserve_spawn_slot(Some(1)) .expect("slot released after second thread removal"); drop(reservation); @@ -141,15 +141,15 @@ fn release_is_idempotent_for_registered_threads() { #[test] fn failed_spawn_keeps_nickname_marked_used() { - let guards = Arc::new(Guards::default()); - let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot"); + let registry = Arc::new(AgentRegistry::default()); + let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot"); let agent_nickname = reservation .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) .expect("reserve agent name"); assert_eq!(agent_nickname, "alpha"); drop(reservation); - let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot"); + let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot"); let agent_nickname = reservation .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) .expect("unused name should still be preferred"); @@ -158,8 +158,10 @@ fn failed_spawn_keeps_nickname_marked_used() { #[test] fn agent_nickname_resets_used_pool_when_exhausted() { - let guards = Arc::new(Guards::default()); - let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let registry = Arc::new(AgentRegistry::default()); + let mut first = registry + .reserve_spawn_slot(None) + .expect("reserve first slot"); let first_name = first .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) .expect("reserve first agent name"); @@ -167,14 +169,14 @@ fn agent_nickname_resets_used_pool_when_exhausted() { first.commit(agent_metadata(first_id)); assert_eq!(first_name, "alpha"); - let mut second = guards + let mut second = registry .reserve_spawn_slot(None) .expect("reserve second slot"); let second_name = second .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) .expect("name should be reused after pool reset"); assert_eq!(second_name, "alpha the 2nd"); - let active_agents = guards + let active_agents = registry .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -183,9 +185,11 @@ fn agent_nickname_resets_used_pool_when_exhausted() { #[test] fn released_nickname_stays_used_until_pool_reset() { - let guards = Arc::new(Guards::default()); + let registry = Arc::new(AgentRegistry::default()); - let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let mut first = registry + .reserve_spawn_slot(None) + .expect("reserve first slot"); let first_name = first .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) .expect("reserve first agent name"); @@ -193,9 +197,9 @@ fn released_nickname_stays_used_until_pool_reset() { first.commit(agent_metadata(first_id)); assert_eq!(first_name, "alpha"); - guards.release_spawned_thread(first_id); + registry.release_spawned_thread(first_id); - let mut second = guards + let mut second = registry .reserve_spawn_slot(None) .expect("reserve second slot"); let second_name = second @@ -204,15 +208,17 @@ fn released_nickname_stays_used_until_pool_reset() { assert_eq!(second_name, "beta"); let second_id = ThreadId::new(); second.commit(agent_metadata(second_id)); - guards.release_spawned_thread(second_id); + registry.release_spawned_thread(second_id); - let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot"); + let mut third = registry + .reserve_spawn_slot(None) + .expect("reserve third slot"); let third_name = third .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) .expect("pool reset should permit a duplicate"); let expected_names = HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]); assert!(expected_names.contains(&third_name)); - let active_agents = guards + let active_agents = registry .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -221,18 +227,20 @@ fn released_nickname_stays_used_until_pool_reset() { #[test] fn repeated_resets_advance_the_ordinal_suffix() { - let guards = Arc::new(Guards::default()); + let registry = Arc::new(AgentRegistry::default()); - let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let mut first = registry + .reserve_spawn_slot(None) + .expect("reserve first slot"); let first_name = first .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) .expect("reserve first agent name"); let first_id = ThreadId::new(); first.commit(agent_metadata(first_id)); assert_eq!(first_name, "Plato"); - guards.release_spawned_thread(first_id); + registry.release_spawned_thread(first_id); - let mut second = guards + let mut second = registry .reserve_spawn_slot(None) .expect("reserve second slot"); let second_name = second @@ -241,14 +249,16 @@ fn repeated_resets_advance_the_ordinal_suffix() { let second_id = ThreadId::new(); second.commit(agent_metadata(second_id)); assert_eq!(second_name, "Plato the 2nd"); - guards.release_spawned_thread(second_id); + registry.release_spawned_thread(second_id); - let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot"); + let mut third = registry + .reserve_spawn_slot(None) + .expect("reserve third slot"); let third_name = third .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) .expect("reserve third agent name"); assert_eq!(third_name, "Plato the 3rd"); - let active_agents = guards + let active_agents = registry .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -257,27 +267,29 @@ fn repeated_resets_advance_the_ordinal_suffix() { #[test] fn register_root_thread_indexes_root_path() { - let guards = Arc::new(Guards::default()); + let registry = Arc::new(AgentRegistry::default()); let root_thread_id = ThreadId::new(); - guards.register_root_thread(root_thread_id); + registry.register_root_thread(root_thread_id); assert_eq!( - guards.agent_id_for_path(&AgentPath::root()), + registry.agent_id_for_path(&AgentPath::root()), Some(root_thread_id) ); } #[test] fn reserved_agent_path_is_released_when_spawn_fails() { - let guards = Arc::new(Guards::default()); - let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let registry = Arc::new(AgentRegistry::default()); + let mut first = registry + .reserve_spawn_slot(None) + .expect("reserve first slot"); first .reserve_agent_path(&agent_path("/root/researcher")) .expect("reserve first path"); drop(first); - let mut second = guards + let mut second = registry .reserve_spawn_slot(None) .expect("reserve second slot"); second @@ -287,9 +299,9 @@ fn reserved_agent_path_is_released_when_spawn_fails() { #[test] fn committed_agent_path_is_indexed_until_release() { - let guards = Arc::new(Guards::default()); + let registry = Arc::new(AgentRegistry::default()); let thread_id = ThreadId::new(); - let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot"); + let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot"); reservation .reserve_agent_path(&agent_path("/root/researcher")) .expect("reserve path"); @@ -300,13 +312,13 @@ fn committed_agent_path_is_indexed_until_release() { }); assert_eq!( - guards.agent_id_for_path(&agent_path("/root/researcher")), + registry.agent_id_for_path(&agent_path("/root/researcher")), Some(thread_id) ); - guards.release_spawned_thread(thread_id); + registry.release_spawned_thread(thread_id); assert_eq!( - guards.agent_id_for_path(&agent_path("/root/researcher")), + registry.agent_id_for_path(&agent_path("/root/researcher")), None ); } From d1088158b8cc230c14613cea9467fc3ad4ff100a Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 23 Mar 2026 09:46:51 -0700 Subject: [PATCH 13/38] fix: fall back to vendored bubblewrap when system bwrap lacks --argv0 (#15338) ## Why Fixes [#15283](https://github.com/openai/codex/issues/15283), where sandboxed tool calls fail on older distro `bubblewrap` builds because `/usr/bin/bwrap` does not understand `--argv0`. The upstream [bubblewrap v0.9.0 release notes](https://github.com/containers/bubblewrap/releases/tag/v0.9.0) explicitly call out `Add --argv0`. Flipping `use_legacy_landlock` globally works around that compatibility bug, but it also weakens the default Linux sandbox and breaks proxy-routed and split-policy cases called out in review. The follow-up Linux CI failure was in the new launcher test rather than the launcher logic: the fake `bwrap` helper stayed open for writing, so Linux would not exec it. This update also closes the user-visibility gap from review by surfacing the same startup warning when `/usr/bin/bwrap` is present but too old for `--argv0`, not only when it is missing. ## What Changed - keep `use_legacy_landlock` default-disabled - teach `codex-rs/linux-sandbox/src/launcher.rs` to fall back to the vendored bubblewrap build when `/usr/bin/bwrap` does not advertise `--argv0` support - add launcher tests for supported, unsupported, and missing system `bwrap` - write the fake `bwrap` test helper to a closed temp path so the supported-path launcher test works on Linux too - extend the startup warning path so Codex warns when `/usr/bin/bwrap` is missing or too old to support `--argv0` - mirror the warning/fallback wording across `codex-rs/linux-sandbox/README.md` and `codex-rs/core/README.md`, including that the fallback is the vendored bubblewrap compiled into the binary - cite the upstream `bubblewrap` release that introduced `--argv0` ## Verification - `bazel test --config=remote --platforms=//:rbe //codex-rs/linux-sandbox:linux-sandbox-unit-tests --test_filter=launcher::tests::prefers_system_bwrap_when_help_lists_argv0 --test_output=errors` - `cargo test -p codex-core system_bwrap_warning` - `cargo check -p codex-exec -p codex-tui -p codex-tui-app-server -p codex-app-server` - `just argument-comment-lint` --- codex-rs/app-server/src/lib.rs | 2 +- codex-rs/core/README.md | 8 ++- codex-rs/core/src/config/config_tests.rs | 69 +++++++++++++++--- codex-rs/core/src/config/mod.rs | 45 +++++++++--- codex-rs/exec/src/lib.rs | 2 +- codex-rs/linux-sandbox/README.md | 20 +++--- codex-rs/linux-sandbox/src/launcher.rs | 91 +++++++++++++++++++++++- codex-rs/tui/src/app.rs | 6 +- codex-rs/tui_app_server/src/app.rs | 6 +- 9 files changed, 210 insertions(+), 39 deletions(-) diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 8b4afc23d0..a4994d34b8 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -479,7 +479,7 @@ pub async fn run_main_with_transport( range: None, }); } - if let Some(warning) = codex_core::config::missing_system_bwrap_warning() { + if let Some(warning) = codex_core::config::system_bwrap_warning() { config_warnings.push(ConfigWarningNotification { summary: warning, details: None, diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 3558fd9f60..c27ec5460e 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -61,9 +61,11 @@ cases like `/repo = write`, `/repo/a = none`, `/repo/a/b = write`, where the more specific writable child must reopen under a denied parent. The Linux sandbox helper prefers `/usr/bin/bwrap` whenever it is available and -falls back to the vendored bubblewrap path otherwise. When `/usr/bin/bwrap` is -missing, Codex also surfaces a startup warning through its normal notification -path instead of printing directly from the sandbox helper. +supports the required argv-rewrite flags, and falls back to the vendored +bubblewrap path compiled into the binary otherwise. When `/usr/bin/bwrap` is +missing or too old to support the required flags, Codex also surfaces a startup +warning through its normal notification path instead of printing directly from +the sandbox helper. ### Windows diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 65c05468e0..7786b30873 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5582,16 +5582,69 @@ shell_tool = true Ok(()) } +#[cfg(target_os = "linux")] #[test] -fn missing_system_bwrap_warning_matches_system_bwrap_presence() { - #[cfg(target_os = "linux")] - assert_eq!( - missing_system_bwrap_warning().is_some(), - !Path::new("/usr/bin/bwrap").is_file() - ); +fn system_bwrap_warning_reports_missing_system_bwrap() { + let warning = system_bwrap_warning_for_path(Path::new("/definitely/not/a/bwrap")) + .expect("missing system bwrap should emit a warning"); - #[cfg(not(target_os = "linux"))] - assert!(missing_system_bwrap_warning().is_none()); + assert!(warning.contains("could not find system bubblewrap")); +} + +#[cfg(target_os = "linux")] +#[test] +fn system_bwrap_warning_reports_too_old_system_bwrap() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +if [ "$1" = "--help" ]; then + echo 'usage: bwrap [OPTION...] COMMAND' + exit 0 +fi +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + let warning = system_bwrap_warning_for_path(fake_bwrap_path) + .expect("old system bwrap should emit a warning"); + + assert!(warning.contains("too old to support `--argv0`")); +} + +#[cfg(target_os = "linux")] +#[test] +fn system_bwrap_warning_skips_supported_system_bwrap() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +if [ "$1" = "--help" ]; then + echo ' --argv0 PROGRAM' + exit 0 +fi +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + + assert_eq!(system_bwrap_warning_for_path(fake_bwrap_path), None); +} + +#[cfg(not(target_os = "linux"))] +#[test] +fn system_bwrap_warning_is_disabled_off_linux() { + assert!(system_bwrap_warning().is_none()); +} + +#[cfg(target_os = "linux")] +fn write_fake_bwrap(contents: &str) -> tempfile::TempPath { + use std::fs; + use std::os::unix::fs::PermissionsExt; + use tempfile::NamedTempFile; + + // Linux rejects exec-ing a file that is still open for writing. + let path = NamedTempFile::new().expect("temp file").into_temp_path(); + fs::write(&path, contents).expect("write fake bwrap"); + let permissions = fs::Permissions::from_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod fake bwrap"); + path } #[tokio::test] diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6df989db16..cbbd023905 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -96,6 +96,8 @@ use std::collections::HashMap; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; +#[cfg(target_os = "linux")] +use std::process::Command; use crate::config::permissions::compile_permission_profile; use crate::config::permissions::get_readable_roots_required_for_codex_runtime; @@ -153,21 +155,46 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [ ]; #[cfg(target_os = "linux")] -pub fn missing_system_bwrap_warning() -> Option { - if Path::new(SYSTEM_BWRAP_PATH).is_file() { - None - } else { - Some(format!( - "Codex could not find system bubblewrap at {SYSTEM_BWRAP_PATH}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime." - )) - } +pub fn system_bwrap_warning() -> Option { + system_bwrap_warning_for_path(Path::new(SYSTEM_BWRAP_PATH)) } #[cfg(not(target_os = "linux"))] -pub fn missing_system_bwrap_warning() -> Option { +pub fn system_bwrap_warning() -> Option { None } +#[cfg(target_os = "linux")] +fn system_bwrap_warning_for_path(system_bwrap_path: &Path) -> Option { + if !system_bwrap_path.is_file() { + return Some(format!( + "Codex could not find system bubblewrap at {}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.", + system_bwrap_path.display() + )); + } + if system_bwrap_supports_argv0(system_bwrap_path) { + return None; + } + + Some(format!( + "Codex found system bubblewrap at {}, but it is too old to support `--argv0`. Please upgrade bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.", + system_bwrap_path.display() + )) +} + +#[cfg(target_os = "linux")] +fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool { + // bubblewrap added `--argv0` in v0.9.0: + // https://github.com/containers/bubblewrap/releases/tag/v0.9.0 + let output = match Command::new(system_bwrap_path).arg("--help").output() { + Ok(output) => output, + Err(_) => return false, + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + stdout.contains("--argv0") || stderr.contains("--argv0") +} + fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?; let trimmed = raw.trim(); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f648a63952..c10b86f696 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -669,7 +669,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { // Print the effective configuration and initial request so users can see what Codex // is using. event_processor.print_config_summary(&config, &prompt_summary, &session_configured); - if !json_mode && let Some(message) = codex_core::config::missing_system_bwrap_warning() { + if !json_mode && let Some(message) = codex_core::config::system_bwrap_warning() { let _ = event_processor.process_event(Event { id: String::new(), msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }), diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 3fde7d9738..ac34fb4edc 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -8,19 +8,23 @@ This crate is responsible for producing: - this should also be true of the `codex` multitool CLI On Linux, the bubblewrap pipeline prefers the system `/usr/bin/bwrap` whenever -it is available. If `/usr/bin/bwrap` is missing, the helper still falls back to +it is available and supports the required argv-rewrite flags. If `/usr/bin/bwrap` +is missing or too old to support the required flags, the helper falls back to the vendored bubblewrap path compiled into this binary. -Codex also surfaces a startup warning when `/usr/bin/bwrap` is missing so users -know it is falling back to the vendored helper. +Codex also surfaces a startup warning when `/usr/bin/bwrap` is missing or too +old to support the required flags so users know it is falling back to the +vendored helper. **Current Behavior** - Legacy `SandboxPolicy` / `sandbox_mode` configs remain supported. - Bubblewrap is the default filesystem sandbox pipeline. -- If `/usr/bin/bwrap` is present, the helper uses it. -- If `/usr/bin/bwrap` is missing, the helper falls back to the vendored - bubblewrap path. -- If `/usr/bin/bwrap` is missing, Codex also surfaces a startup warning instead - of printing directly from the sandbox helper. +- If `/usr/bin/bwrap` is present and supports the required argv-rewrite flags, + the helper uses it. +- If `/usr/bin/bwrap` is missing or too old to support the required flags, the + helper falls back to the vendored bubblewrap path. +- If `/usr/bin/bwrap` is missing or too old to support the required flags, + Codex also surfaces a startup warning instead of printing directly from the + sandbox helper. - Legacy Landlock + mount protections remain available as an explicit legacy fallback path. - Set `features.use_legacy_landlock = true` (or CLI `-c use_legacy_landlock=true`) diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index 37a860e085..7d7e040844 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -4,6 +4,8 @@ use std::os::fd::AsRawFd; use std::os::raw::c_char; use std::os::unix::ffi::OsStrExt; use std::path::Path; +use std::process::Command; +use std::sync::OnceLock; use crate::vendored_bwrap::exec_vendored_bwrap; use codex_utils_absolute_path::AbsolutePathBuf; @@ -24,17 +26,41 @@ pub(crate) fn exec_bwrap(argv: Vec, preserved_files: Vec) -> ! { } fn preferred_bwrap_launcher() -> BubblewrapLauncher { - if !Path::new(SYSTEM_BWRAP_PATH).is_file() { + static LAUNCHER: OnceLock = OnceLock::new(); + LAUNCHER + .get_or_init(|| preferred_bwrap_launcher_for_path(Path::new(SYSTEM_BWRAP_PATH))) + .clone() +} + +fn preferred_bwrap_launcher_for_path(system_bwrap_path: &Path) -> BubblewrapLauncher { + if !system_bwrap_supports_argv0(system_bwrap_path) { return BubblewrapLauncher::Vendored; } - let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(SYSTEM_BWRAP_PATH) { + let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(system_bwrap_path) { Ok(path) => path, - Err(err) => panic!("failed to normalize system bubblewrap path {SYSTEM_BWRAP_PATH}: {err}"), + Err(err) => panic!( + "failed to normalize system bubblewrap path {}: {err}", + system_bwrap_path.display() + ), }; BubblewrapLauncher::System(system_bwrap_path) } +fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool { + // bubblewrap added `--argv0` in v0.9.0: + // https://github.com/containers/bubblewrap/releases/tag/v0.9.0 + // Older distro packages (for example Ubuntu 20.04/22.04) ship builds that + // reject `--argv0`, so prefer the vendored build in that case. + let output = match Command::new(system_bwrap_path).arg("--help").output() { + Ok(output) => output, + Err(_) => return false, + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + stdout.contains("--argv0") || stderr.contains("--argv0") +} + fn exec_system_bwrap( program: &AbsolutePathBuf, argv: Vec, @@ -100,7 +126,57 @@ fn clear_cloexec(fd: libc::c_int) { mod tests { use super::*; use pretty_assertions::assert_eq; + use std::fs; + use std::os::unix::fs::PermissionsExt; use tempfile::NamedTempFile; + use tempfile::TempPath; + + #[test] + fn prefers_system_bwrap_when_help_lists_argv0() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +if [ "$1" = "--help" ]; then + echo ' --argv0 PROGRAM' + exit 0 +fi +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + let expected = AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"); + + assert_eq!( + preferred_bwrap_launcher_for_path(fake_bwrap_path), + BubblewrapLauncher::System(expected) + ); + } + + #[test] + fn falls_back_to_vendored_when_system_bwrap_lacks_argv0() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +if [ "$1" = "--help" ]; then + echo 'usage: bwrap [OPTION...] COMMAND' + exit 0 +fi +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + + assert_eq!( + preferred_bwrap_launcher_for_path(fake_bwrap_path), + BubblewrapLauncher::Vendored + ); + } + + #[test] + fn falls_back_to_vendored_when_system_bwrap_is_missing() { + assert_eq!( + preferred_bwrap_launcher_for_path(Path::new("/definitely/not/a/bwrap")), + BubblewrapLauncher::Vendored + ); + } #[test] fn preserved_files_are_made_inheritable_for_system_exec() { @@ -131,4 +207,13 @@ mod tests { } flags } + + fn write_fake_bwrap(contents: &str) -> TempPath { + // Linux rejects exec-ing a file that is still open for writing. + let path = NamedTempFile::new().expect("temp file").into_temp_path(); + fs::write(&path, contents).expect("write fake bwrap"); + let permissions = fs::Permissions::from_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod fake bwrap"); + path + } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4aa51df21c..358b1fe5bb 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -398,8 +398,8 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) ))); } -fn emit_missing_system_bwrap_warning(app_event_tx: &AppEventSender) { - let Some(message) = codex_core::config::missing_system_bwrap_warning() else { +fn emit_system_bwrap_warning(app_event_tx: &AppEventSender) { + let Some(message) = codex_core::config::system_bwrap_warning() else { return; }; @@ -2197,7 +2197,7 @@ impl App { let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); emit_project_config_warnings(&app_event_tx, &config); - emit_missing_system_bwrap_warning(&app_event_tx); + emit_system_bwrap_warning(&app_event_tx); emit_custom_prompt_deprecation_notice(&app_event_tx, &config.codex_home).await; tui.set_notification_method(config.tui_notification_method); diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index d4569ae7a8..79c15c29ae 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -435,8 +435,8 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) ))); } -fn emit_missing_system_bwrap_warning(app_event_tx: &AppEventSender) { - let Some(message) = codex_core::config::missing_system_bwrap_warning() else { +fn emit_system_bwrap_warning(app_event_tx: &AppEventSender) { + let Some(message) = codex_core::config::system_bwrap_warning() else { return; }; @@ -2794,7 +2794,7 @@ impl App { let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); emit_project_config_warnings(&app_event_tx, &config); - emit_missing_system_bwrap_warning(&app_event_tx); + emit_system_bwrap_warning(&app_event_tx); tui.set_notification_method(config.tui_notification_method); let harness_overrides = From 2887f16cb97b6fd365fb4b2cf8d1c491ae78fa01 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 23 Mar 2026 16:48:54 +0000 Subject: [PATCH 14/38] fix: cargo deny (#15520) --- MODULE.bazel.lock | 2 +- codex-rs/Cargo.lock | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 6305cd169b..e0d384767b 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1328,7 +1328,7 @@ "system-configuration-sys_0.6.0": "{\"dependencies\":[{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\"},{\"name\":\"libc\",\"req\":\"^0.2.149\"}],\"features\":{}}", "system-configuration_0.6.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"core-foundation\",\"req\":\"^0.9\"},{\"name\":\"system-configuration-sys\",\"req\":\"^0.6\"}],\"features\":{}}", "tagptr_0.2.0": "{\"dependencies\":[],\"features\":{}}", - "tar_0.4.44": "{\"dependencies\":[{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", + "tar_0.4.45": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"astral-tokio-tar\",\"req\":\"^0.5\"},{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", "tempfile_3.24.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.3\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", "temporal_capi_0.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"unstable\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.1.2\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}", "temporal_rs_0.1.2": "{\"dependencies\":[{\"name\":\"core_maths\",\"req\":\"^0.1.1\"},{\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.64\"},{\"default_features\":false,\"features\":[\"unstable\",\"compiled_data\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"features\":[\"duration\"],\"name\":\"ixdtf\",\"req\":\"^0.6.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"}],\"features\":{\"compiled_data\":[\"tzdb\"],\"default\":[\"sys\"],\"float64_representable_durations\":[],\"log\":[\"dep:log\"],\"std\":[],\"sys\":[\"std\",\"compiled_data\",\"dep:web-time\",\"dep:iana-time-zone\"],\"tzdb\":[\"std\",\"timezone_provider/tzif\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d0917ee38d..778dee3ea7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -380,7 +380,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -391,7 +391,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3821,7 +3821,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4066,7 +4066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5511,7 +5511,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6274,7 +6274,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6918,7 +6918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -8340,7 +8340,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9754,9 +9754,9 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -9773,7 +9773,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -11219,7 +11219,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] From 54801634e17e114c5e07be3e1aba6dc5a4ff7879 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 23 Mar 2026 10:10:17 -0700 Subject: [PATCH 15/38] Label plugins as plugins, and hide skills/apps for given plugin (#15279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Duplicate app mentions are now suppressed when they’re plugin-backed with the same display name. - Remaining connector mentions now label category as [Plugin] when plugin metadata is present, otherwise [App]. - Mention result lists are now capped to 8 rows after filtering. - Updates both tui and tui_app_server with the same changes. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 108 +++++++++++++++++- .../tui/src/bottom_pane/file_search_popup.rs | 32 +++++- codex-rs/tui/src/bottom_pane/skill_popup.rs | 41 +++++++ .../src/bottom_pane/chat_composer.rs | 108 +++++++++++++++++- .../src/bottom_pane/file_search_popup.rs | 32 +++++- .../src/bottom_pane/skill_popup.rs | 41 +++++++ 6 files changed, 358 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8116cf972b..90106f31fd 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3584,6 +3584,13 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); + let plugin_display_names: HashSet = + self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { + plugins + .iter() + .map(|plugin| plugin.display_name.to_ascii_lowercase()) + .collect() + }); if let Some(skills) = self.skills.as_ref() { for skill in skills { @@ -3666,18 +3673,30 @@ impl ChatComposer { if !connector.is_accessible || !connector.is_enabled { continue; } + let plugin_backed_connector = connector + .plugin_display_names + .iter() + .any(|name| plugin_display_names.contains(&name.to_ascii_lowercase())); + if plugin_backed_connector { + continue; + } let display_name = connectors::connector_display_label(connector); let description = Some(Self::connector_brief_description(connector)); let slug = codex_core::connectors::connector_mention_slug(connector); let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()]; let connector_id = connector.id.as_str(); + let category_tag = if connector.plugin_display_names.is_empty() { + "[App]".to_string() + } else { + "[Plugin]".to_string() + }; mentions.push(MentionItem { display_name: display_name.clone(), description, insert_text: format!("${slug}"), search_terms, path: Some(format!("app://{connector_id}")), - category_tag: Some("[App]".to_string()), + category_tag: Some(category_tag), sort_rank: 1, }); } @@ -5383,6 +5402,93 @@ mod tests { assert_eq!(mention.path, Some("plugin://sample@test".to_string())); } + #[test] + fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_connectors_enabled(true); + composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new()); + composer.set_skill_mentions(Some(vec![SkillMetadata { + name: "google-calendar:availability".to_string(), + description: "Find availability and plan event changes".to_string(), + short_description: None, + interface: Some(codex_core::skills::model::SkillInterface { + display_name: Some("Google Calendar".to_string()), + short_description: None, + icon_small: None, + icon_large: None, + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + permission_profile: None, + managed_network_override: None, + path_to_skills_md: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"), + scope: codex_protocol::protocol::SkillScope::Repo, + }])); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "google-calendar@debug".to_string(), + display_name: "Google Calendar".to_string(), + description: Some( + "Connect Google Calendar for scheduling, availability, and event management." + .to_string(), + ), + has_skills: true, + mcp_server_names: vec!["google-calendar".to_string()], + app_connector_ids: vec![codex_core::plugins::AppConnectorId( + "google_calendar".to_string(), + )], + }])); + composer.set_connector_mentions(Some(ConnectorsSnapshot { + connectors: vec![AppInfo { + id: "google_calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Look up events and availability".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/google-calendar".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: vec!["Google Calendar".to_string()], + }], + })); + + let mut mention_summaries: Vec<_> = composer + .mention_items() + .into_iter() + .map(|mention| (mention.display_name, mention.category_tag, mention.path)) + .collect(); + mention_summaries.sort(); + + assert_eq!( + mention_summaries, + vec![ + ( + "Google Calendar".to_string(), + Some("[Plugin]".to_string()), + Some("plugin://google-calendar@debug".to_string()), + ), + ( + "Google Calendar".to_string(), + Some("[Skill]".to_string()), + Some("/tmp/repo/google-calendar/SKILL.md".to_string()), + ), + ] + ); + } + #[test] fn plugin_mention_popup_snapshot() { snapshot_composer_state("plugin_mention_popup", false, |composer| { diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index c1c5296648..fe903a0b37 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -70,7 +70,7 @@ impl FileSearchPopup { } self.display_query = query.to_string(); - self.matches = matches; + self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect(); self.waiting = false; let len = self.matches.len(); self.state.clamp_selection(len); @@ -152,3 +152,33 @@ impl WidgetRef for &FileSearchPopup { ); } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_file_search::MatchType; + use pretty_assertions::assert_eq; + + fn file_match(index: usize) -> FileMatch { + FileMatch { + score: index as u32, + path: PathBuf::from(format!("src/file_{index:02}.rs")), + match_type: MatchType::File, + root: PathBuf::from("/tmp/repo"), + indices: None, + } + } + + #[test] + fn set_matches_keeps_only_the_first_page_of_results() { + let mut popup = FileSearchPopup::new(); + popup.set_query("file"); + popup.set_matches("file", (0..(MAX_POPUP_ROWS + 2)).map(file_match).collect()); + + assert_eq!( + popup.matches, + (0..MAX_POPUP_ROWS).map(file_match).collect::>() + ); + assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16); + } +} diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 841ce23b8b..061d3f3b82 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -180,6 +180,7 @@ impl SkillPopup { }) }); + out.truncate(MAX_POPUP_ROWS); out } } @@ -229,3 +230,43 @@ fn skill_popup_hint_line() -> Line<'static> { " to close".into(), ]) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn mention_item(index: usize) -> MentionItem { + MentionItem { + display_name: format!("Mention {index:02}"), + description: Some(format!("Description {index:02}")), + insert_text: format!("$mention-{index:02}"), + search_terms: vec![format!("mention-{index:02}")], + path: Some(format!("skill://mention-{index:02}")), + category_tag: Some("[Skill]".to_string()), + sort_rank: 1, + } + } + + #[test] + fn filtered_mentions_are_capped_to_max_popup_rows() { + let popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect()); + + let filtered_names: Vec = popup + .filtered_items() + .into_iter() + .map(|idx| popup.mentions[idx].display_name.clone()) + .collect(); + + assert_eq!( + filtered_names, + (0..MAX_POPUP_ROWS) + .map(|idx| format!("Mention {idx:02}")) + .collect::>() + ); + assert_eq!( + popup.calculate_required_height(72), + (MAX_POPUP_ROWS as u16) + 2 + ); + } +} diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index 0e1f3e08d6..06d8396f8c 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -3599,6 +3599,13 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); + let plugin_display_names: HashSet = + self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { + plugins + .iter() + .map(|plugin| plugin.display_name.to_ascii_lowercase()) + .collect() + }); if let Some(skills) = self.skills.as_ref() { for skill in skills { @@ -3681,18 +3688,30 @@ impl ChatComposer { if !connector.is_accessible || !connector.is_enabled { continue; } + let plugin_backed_connector = connector + .plugin_display_names + .iter() + .any(|name| plugin_display_names.contains(&name.to_ascii_lowercase())); + if plugin_backed_connector { + continue; + } let display_name = connectors::connector_display_label(connector); let description = Some(Self::connector_brief_description(connector)); let slug = codex_core::connectors::connector_mention_slug(connector); let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()]; let connector_id = connector.id.as_str(); + let category_tag = if connector.plugin_display_names.is_empty() { + "[App]".to_string() + } else { + "[Plugin]".to_string() + }; mentions.push(MentionItem { display_name: display_name.clone(), description, insert_text: format!("${slug}"), search_terms, path: Some(format!("app://{connector_id}")), - category_tag: Some("[App]".to_string()), + category_tag: Some(category_tag), sort_rank: 1, }); } @@ -5398,6 +5417,93 @@ mod tests { assert_eq!(mention.path, Some("plugin://sample@test".to_string())); } + #[test] + fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_connectors_enabled(true); + composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new()); + composer.set_skill_mentions(Some(vec![SkillMetadata { + name: "google-calendar:availability".to_string(), + description: "Find availability and plan event changes".to_string(), + short_description: None, + interface: Some(codex_core::skills::model::SkillInterface { + display_name: Some("Google Calendar".to_string()), + short_description: None, + icon_small: None, + icon_large: None, + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + permission_profile: None, + managed_network_override: None, + path_to_skills_md: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"), + scope: codex_protocol::protocol::SkillScope::Repo, + }])); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "google-calendar@debug".to_string(), + display_name: "Google Calendar".to_string(), + description: Some( + "Connect Google Calendar for scheduling, availability, and event management." + .to_string(), + ), + has_skills: true, + mcp_server_names: vec!["google-calendar".to_string()], + app_connector_ids: vec![codex_core::plugins::AppConnectorId( + "google_calendar".to_string(), + )], + }])); + composer.set_connector_mentions(Some(ConnectorsSnapshot { + connectors: vec![AppInfo { + id: "google_calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Look up events and availability".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/google-calendar".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: vec!["Google Calendar".to_string()], + }], + })); + + let mut mention_summaries: Vec<_> = composer + .mention_items() + .into_iter() + .map(|mention| (mention.display_name, mention.category_tag, mention.path)) + .collect(); + mention_summaries.sort(); + + assert_eq!( + mention_summaries, + vec![ + ( + "Google Calendar".to_string(), + Some("[Plugin]".to_string()), + Some("plugin://google-calendar@debug".to_string()), + ), + ( + "Google Calendar".to_string(), + Some("[Skill]".to_string()), + Some("/tmp/repo/google-calendar/SKILL.md".to_string()), + ), + ] + ); + } + #[test] fn plugin_mention_popup_snapshot() { snapshot_composer_state("plugin_mention_popup", false, |composer| { diff --git a/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs index c1c5296648..fe903a0b37 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs @@ -70,7 +70,7 @@ impl FileSearchPopup { } self.display_query = query.to_string(); - self.matches = matches; + self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect(); self.waiting = false; let len = self.matches.len(); self.state.clamp_selection(len); @@ -152,3 +152,33 @@ impl WidgetRef for &FileSearchPopup { ); } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_file_search::MatchType; + use pretty_assertions::assert_eq; + + fn file_match(index: usize) -> FileMatch { + FileMatch { + score: index as u32, + path: PathBuf::from(format!("src/file_{index:02}.rs")), + match_type: MatchType::File, + root: PathBuf::from("/tmp/repo"), + indices: None, + } + } + + #[test] + fn set_matches_keeps_only_the_first_page_of_results() { + let mut popup = FileSearchPopup::new(); + popup.set_query("file"); + popup.set_matches("file", (0..(MAX_POPUP_ROWS + 2)).map(file_match).collect()); + + assert_eq!( + popup.matches, + (0..MAX_POPUP_ROWS).map(file_match).collect::>() + ); + assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16); + } +} diff --git a/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs index 841ce23b8b..061d3f3b82 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs @@ -180,6 +180,7 @@ impl SkillPopup { }) }); + out.truncate(MAX_POPUP_ROWS); out } } @@ -229,3 +230,43 @@ fn skill_popup_hint_line() -> Line<'static> { " to close".into(), ]) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn mention_item(index: usize) -> MentionItem { + MentionItem { + display_name: format!("Mention {index:02}"), + description: Some(format!("Description {index:02}")), + insert_text: format!("$mention-{index:02}"), + search_terms: vec![format!("mention-{index:02}")], + path: Some(format!("skill://mention-{index:02}")), + category_tag: Some("[Skill]".to_string()), + sort_rank: 1, + } + } + + #[test] + fn filtered_mentions_are_capped_to_max_popup_rows() { + let popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect()); + + let filtered_names: Vec = popup + .filtered_items() + .into_iter() + .map(|idx| popup.mentions[idx].display_name.clone()) + .collect(); + + assert_eq!( + filtered_names, + (0..MAX_POPUP_ROWS) + .map(|idx| format!("Mention {idx:02}")) + .collect::>() + ); + assert_eq!( + popup.calculate_required_height(72), + (MAX_POPUP_ROWS as u16) + 2 + ); + } +} From e838645fa264ef108bb66b74ad284224d2a10ac0 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Mon, 23 Mar 2026 10:19:44 -0700 Subject: [PATCH 16/38] tui: queue follow-ups during manual /compact (#15259) ## Summary - queue input after the user submits `/compact` until that manual compact turn ends - mirror the same behavior in the app-server TUI - add regressions for input queued before compact starts and while it is running Co-authored-by: Codex --- .../schema/json/ServerNotification.json | 29 ++ .../codex_app_server_protocol.schemas.json | 29 ++ .../codex_app_server_protocol.v2.schemas.json | 29 ++ .../schema/json/v2/ErrorNotification.json | 29 ++ .../schema/json/v2/ReviewStartResponse.json | 29 ++ .../schema/json/v2/ThreadForkResponse.json | 29 ++ .../schema/json/v2/ThreadListResponse.json | 29 ++ .../json/v2/ThreadMetadataUpdateResponse.json | 29 ++ .../schema/json/v2/ThreadReadResponse.json | 29 ++ .../schema/json/v2/ThreadResumeResponse.json | 29 ++ .../json/v2/ThreadRollbackResponse.json | 29 ++ .../schema/json/v2/ThreadStartResponse.json | 29 ++ .../json/v2/ThreadStartedNotification.json | 29 ++ .../json/v2/ThreadUnarchiveResponse.json | 29 ++ .../json/v2/TurnCompletedNotification.json | 29 ++ .../schema/json/v2/TurnStartResponse.json | 29 ++ .../json/v2/TurnStartedNotification.json | 29 ++ .../schema/typescript/v2/CodexErrorInfo.ts | 3 +- .../typescript/v2/NonSteerableTurnKind.ts | 5 + .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 46 +++ codex-rs/app-server/README.md | 12 +- .../app-server/src/codex_message_processor.rs | 39 +- codex-rs/core/src/codex.rs | 83 +++- codex-rs/core/src/codex_tests.rs | 38 ++ codex-rs/protocol/src/protocol.rs | 27 +- codex-rs/tui/src/app.rs | 8 +- codex-rs/tui/src/bottom_pane/mod.rs | 23 +- .../src/bottom_pane/pending_input_preview.rs | 49 ++- ..._pending_steers_above_queued_messages.snap | 17 +- codex-rs/tui/src/chatwidget.rs | 144 +++++-- ...compact_queues_user_messages_snapshot.snap | 21 ++ ..._review_queues_user_messages_snapshot.snap | 6 +- codex-rs/tui/src/chatwidget/tests.rs | 343 ++++++++++++++++- codex-rs/tui_app_server/src/app.rs | 68 +++- .../tui_app_server/src/app_server_session.rs | 4 +- .../tui_app_server/src/bottom_pane/mod.rs | 23 +- .../src/bottom_pane/pending_input_preview.rs | 49 ++- ..._pending_steers_above_queued_messages.snap | 17 +- codex-rs/tui_app_server/src/chatwidget.rs | 156 ++++++-- ...compact_queues_user_messages_snapshot.snap | 21 ++ ..._review_queues_user_messages_snapshot.snap | 6 +- .../tui_app_server/src/chatwidget/tests.rs | 353 +++++++++++++++++- 43 files changed, 1898 insertions(+), 157 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5b06ab539c..7d192f0b01 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -514,6 +514,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -1623,6 +1645,13 @@ ], "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 c24c8ac249..6f71247511 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 @@ -5658,6 +5658,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/v2/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -9115,6 +9137,13 @@ }, "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "OverriddenMetadata": { "properties": { "effectiveValue": true, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c479da94e4..a9c3b96635 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -2251,6 +2251,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -5863,6 +5885,13 @@ }, "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "OverriddenMetadata": { "properties": { "effectiveValue": true, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index 264a901c0f..032743d55d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -112,9 +112,38 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "TurnError": { "properties": { "additionalDetails": { 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 98b485b578..2e0c3605e7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 7740421917..5ae0c5a122 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -196,6 +196,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -589,6 +611,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 55f02fedbc..126d78603a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 300f8d1f30..dfdab228df 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 6c6597a660..8f48dee4b7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 35a41983a4..edccc337da 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -196,6 +196,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -589,6 +611,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 35e03397b0..cc41aac27f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 568c654561..c3b50fee3b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -196,6 +196,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -589,6 +611,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 971233fcde..2240150394 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 94046cd18d..41b0d2d409 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 b0220247aa..770cc920cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 cd9f63bb6c..7f1c3e4948 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 3cc16db922..761ddc9a62 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts index 1ff409a41a..20dc3c5191 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.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 { NonSteerableTurnKind } from "./NonSteerableTurnKind"; /** * This translation layer make sure that we expose codex error code in camel case. @@ -8,4 +9,4 @@ * When 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. */ -export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | "other"; +export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts new file mode 100644 index 0000000000..2624df2ba0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.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 NonSteerableTurnKind = "review" | "compact"; 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 d9cc4758bc..c649aec06a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -194,6 +194,7 @@ export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { NetworkRequirements } from "./NetworkRequirements"; +export type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 57017833a6..a1d5e55621 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -66,6 +66,7 @@ use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; use codex_protocol::protocol::HookScope as CoreHookScope; use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; +use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; @@ -128,6 +129,14 @@ macro_rules! v2_enum_from_core { }; } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NonSteerableTurnKind { + Review, + Compact, +} + /// This translation layer make sure that we expose codex error code in camel case. /// /// When an upstream HTTP status is available (for example, from the Responses API or a provider), @@ -167,6 +176,13 @@ pub enum CodexErrorInfo { #[ts(rename = "httpStatusCode")] http_status_code: Option, }, + /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn + /// cannot accept same-turn steering, for example `/review` or manual `/compact`. + ActiveTurnNotSteerable { + #[serde(rename = "turnKind")] + #[ts(rename = "turnKind")] + turn_kind: NonSteerableTurnKind, + }, Other, } @@ -193,11 +209,25 @@ impl From for CodexErrorInfo { CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } } + CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => { + CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + } + } CoreCodexErrorInfo::Other => CodexErrorInfo::Other, } } } +impl From for NonSteerableTurnKind { + fn from(value: CoreNonSteerableTurnKind) -> Self { + match value { + CoreNonSteerableTurnKind::Review => Self::Review, + CoreNonSteerableTurnKind::Compact => Self::Compact, + } + } +} + #[derive( Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi, )] @@ -7832,6 +7862,22 @@ mod tests { ); } + #[test] + fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() { + let value = CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }; + + assert_eq!( + serde_json::to_value(value).unwrap(), + json!({ + "activeTurnNotSteerable": { + "turnKind": "review" + } + }) + ); + } + #[test] fn dynamic_tool_response_serializes_content_items() { let value = serde_json::to_value(DynamicToolCallResponse { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3248a44424..4d139b7f7e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -140,7 +140,7 @@ Example with notification opt-out: - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. - `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". -- `turn/steer` — add user input to an already in-flight turn without starting a new turn; returns the active `turnId` that accepted the input. +- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. - `thread/realtime/start` — start a thread-scoped realtime session (experimental); returns `{}` and streams `thread/realtime/*` notifications. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. @@ -575,8 +575,8 @@ Use `thread/backgroundTerminals/clean` to terminate all running background termi ### Example: Steer an active turn -Use `turn/steer` to append additional user input to the currently active turn. This does not emit -`turn/started` and does not accept turn context overrides. +Use `turn/steer` to append additional user input to the currently active regular turn. This does +not emit `turn/started` and does not accept turn context overrides. ```json { "method": "turn/steer", "id": 32, "params": { @@ -587,7 +587,9 @@ Use `turn/steer` to append additional user input to the currently active turn. T { "id": 32, "result": { "turnId": "turn_456" } } ``` -`expectedTurnId` is required. If there is no active turn (or `expectedTurnId` does not match the active turn), the request fails with an `invalid request` error. +`expectedTurnId` is required. If there is no active turn, `expectedTurnId` does not match the +active turn, or the active turn kind does not accept same-turn steering (for example review or +manual compaction), the request fails with an `invalid request` error. ### Example: Request a code review @@ -918,6 +920,8 @@ There are additional item-specific events: - `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream - `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion - `ResponseTooManyFailedAttempts { httpStatusCode? }` +- `ActiveTurnNotSteerable { turnKind }`: `turn/start` or `turn/steer` was submitted while the + current active turn was not steerable, for example `/review` or manual `/compact` - `BadRequest` - `Unauthorized` - `SandboxError` diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1b02e4bb6c..8917f392dd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -32,6 +32,7 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -161,6 +162,7 @@ use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; use codex_app_server_protocol::ThreadUnsubscribeStatus; use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; @@ -6131,24 +6133,57 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message) = match err { + let (code, message, data) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), + None, ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), + None, ), + SteerInputError::ActiveTurnNotSteerable { turn_kind } => { + let message = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => { + "cannot steer a review turn".to_string() + } + codex_protocol::protocol::NonSteerableTurnKind::Compact => { + "cannot steer a compact turn".to_string() + } + }; + let error = TurnError { + message: message.clone(), + codex_error_info: Some( + AppServerCodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + ), + additional_details: None, + }; + let data = match serde_json::to_value(error) { + Ok(data) => Some(data), + Err(error) => { + tracing::error!( + ?error, + "failed to serialize active-turn-not-steerable turn error" + ); + None + } + }; + (INVALID_REQUEST_ERROR_CODE, message, data) + } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), + None, ), }; let error = JSONRPCErrorError { code, message, - data: None, + data, }; self.outgoing.send_error(request_id, error).await; } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cbaabe6b84..099edb7c0f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -186,9 +186,41 @@ mod rollout_reconstruction_tests; pub enum SteerInputError { NoActiveTurn(Vec), ExpectedTurnMismatch { expected: String, actual: String }, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, EmptyInput, } +impl SteerInputError { + fn to_error_event(&self) -> ErrorEvent { + match self { + Self::NoActiveTurn(_) => ErrorEvent { + message: "no active turn to steer".to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }, + Self::ExpectedTurnMismatch { expected, actual } => ErrorEvent { + message: format!("expected active turn id `{expected}` but found `{actual}`"), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }, + Self::ActiveTurnNotSteerable { turn_kind } => { + let turn_kind_label = match turn_kind { + NonSteerableTurnKind::Review => "review", + NonSteerableTurnKind::Compact => "compact", + }; + ErrorEvent { + message: format!("cannot steer a {turn_kind_label} turn"), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: *turn_kind, + }), + } + } + Self::EmptyInput => ErrorEvent { + message: "input must not be empty".to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }, + } + } +} + /// Notes from the previous real user turn. /// /// Conceptually this is the same role that `previous_model` used to fill, but @@ -333,6 +365,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_readiness::Readiness; @@ -3859,6 +3892,21 @@ impl Session { }); } + match active_turn.tasks.first().map(|(_, task)| task.kind) { + Some(crate::state::TaskKind::Regular) => {} + Some(crate::state::TaskKind::Review) => { + return Err(SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }); + } + Some(crate::state::TaskKind::Compact) => { + return Err(SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }); + } + None => return Err(SteerInputError::NoActiveTurn(input)), + } + let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); Ok(active_turn_id.clone()) @@ -4526,26 +4574,35 @@ mod handlers { _ => unreachable!(), }; - let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else { + let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else { // new_turn_with_sub_id already emits the error event. return; }; sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; - current_context.session_telemetry.user_prompt(&items); - - // Attempt to inject input into current task. - if let Err(SteerInputError::NoActiveTurn(items)) = - sess.steer_input(items, /*expected_turn_id*/ None).await + match sess + .steer_input(items.clone(), /*expected_turn_id*/ None) + .await { - sess.refresh_mcp_servers_if_requested(¤t_context) + Ok(_) => current_context.session_telemetry.user_prompt(&items), + Err(SteerInputError::NoActiveTurn(items)) => { + current_context.session_telemetry.user_prompt(&items); + sess.refresh_mcp_servers_if_requested(¤t_context) + .await; + sess.spawn_task( + Arc::clone(¤t_context), + items, + crate::tasks::RegularTask::new(), + ) .await; - sess.spawn_task( - Arc::clone(¤t_context), - items, - crate::tasks::RegularTask::new(), - ) - .await; + } + Err(err) => { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Error(err.to_error_event()), + }) + .await; + } } } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index e2ad0982bc..2305cb1fa9 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -24,6 +24,7 @@ use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; @@ -4507,6 +4508,43 @@ async fn steer_input_enforces_expected_turn_id() { } } +#[tokio::test] +async fn steer_input_rejects_non_regular_turns() { + for (task_kind, turn_kind) in [ + (TaskKind::Review, NonSteerableTurnKind::Review), + (TaskKind::Compact, NonSteerableTurnKind::Compact), + ] { + let (sess, _tc, _rx) = make_session_and_context_with_rx().await; + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + let turn_context = sess.new_default_turn_with_sub_id("turn".to_string()).await; + sess.spawn_task( + turn_context, + input, + NeverEndingTask { + kind: task_kind, + listen_to_cancellation_token: true, + }, + ) + .await; + + let steer_input = vec![UserInput::Text { + text: "steer".to_string(), + text_elements: Vec::new(), + }]; + let err = sess + .steer_input(steer_input, /*expected_turn_id*/ None) + .await + .expect_err("steering a non-regular turn should fail"); + + assert_eq!(err, SteerInputError::ActiveTurnNotSteerable { turn_kind }); + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + } +} + #[tokio::test] async fn steer_input_returns_active_turn_id() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 4f7f2616f7..c88c4ecee8 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1538,6 +1538,15 @@ pub enum AgentStatus { NotFound, } +/// Turn kinds that reject same-turn steering. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum NonSteerableTurnKind { + Review, + Compact, +} + /// Codex errors that we expose to clients. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] @@ -1565,6 +1574,11 @@ pub enum CodexErrorInfo { ResponseTooManyFailedAttempts { http_status_code: Option, }, + /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn + /// cannot accept same-turn steering, for example `/review` or manual `/compact`. + ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind, + }, ThreadRollbackFailed, Other, } @@ -1573,7 +1587,7 @@ impl CodexErrorInfo { /// Whether this error should mark the current turn as failed when replaying history. pub fn affects_turn_status(&self) -> bool { match self { - Self::ThreadRollbackFailed => false, + Self::ThreadRollbackFailed | Self::ActiveTurnNotSteerable { .. } => false, Self::ContextWindowExceeded | Self::UsageLimitExceeded | Self::ServerOverloaded @@ -4211,6 +4225,17 @@ mod tests { assert!(!event.affects_turn_status()); } + #[test] + fn active_turn_not_steerable_error_does_not_affect_turn_status() { + let event = ErrorEvent { + message: "cannot steer a review turn".into(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }; + assert!(!event.affects_turn_status()); + } + #[test] fn generic_error_affects_turn_status() { let event = ErrorEvent { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 358b1fe5bb..4086efd985 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5008,7 +5008,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5090,7 +5090,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5171,7 +5171,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5246,7 +5246,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 0c9e16b411..eda37fe9c4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -825,8 +825,10 @@ impl BottomPane { &mut self, queued: Vec, pending_steers: Vec, + rejected_steers: Vec, ) { self.pending_input_preview.pending_steers = pending_steers; + self.pending_input_preview.rejected_steers = rejected_steers; self.pending_input_preview.queued_messages = queued; self.request_redraw(); } @@ -1153,7 +1155,8 @@ impl BottomPane { } let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty(); let has_pending_input = !self.pending_input_preview.queued_messages.is_empty() - || !self.pending_input_preview.pending_steers.is_empty(); + || !self.pending_input_preview.pending_steers.is_empty() + || !self.pending_input_preview.rejected_steers.is_empty(); let has_status_or_footer = self.status.is_some() || !self.unified_exec_footer.is_empty(); let has_inline_previews = has_pending_thread_approvals || has_pending_input; @@ -1556,7 +1559,11 @@ mod tests { StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); @@ -1583,7 +1590,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); pane.hide_status_indicator(); let width = 48; @@ -1611,7 +1622,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 1f38a17773..b9690c28ee 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -10,17 +10,19 @@ use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; -/// Widget that displays pending steers plus user messages queued while a turn is in progress. +/// Widget that displays pending steers plus follow-up messages held while a turn is in progress. /// -/// The widget renders pending steers first, then queued user messages, as two -/// labeled sections. Pending steers explain that they will be submitted after -/// the next tool/result boundary unless the user presses Esc to interrupt and -/// send them immediately. The edit hint at the bottom only appears when there -/// are actual queued user messages to pop 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). +/// The widget renders pending steers first, then rejected steers that will be +/// resubmitted at end of turn, then ordinary queued user messages. Pending +/// steers explain that they will be submitted after the next tool/result +/// boundary unless the user presses Esc to interrupt and send them +/// immediately. The edit hint at the bottom only appears when there are actual +/// queued user messages to pop 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 PendingInputPreview { pub pending_steers: Vec, + pub rejected_steers: Vec, pub queued_messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. @@ -33,6 +35,7 @@ impl PendingInputPreview { pub(crate) fn new() -> Self { Self { pending_steers: Vec::new(), + rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: key_hint::alt(KeyCode::Up), } @@ -67,7 +70,11 @@ impl PendingInputPreview { } fn as_renderable(&self, width: u16) -> Box { - if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 { + if (self.pending_steers.is_empty() + && self.rejected_steers.is_empty() + && self.queued_messages.is_empty()) + || width < 4 + { return Box::new(()); } @@ -96,6 +103,27 @@ impl PendingInputPreview { } } + if !self.rejected_steers.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + Self::push_section_header( + &mut lines, + width, + "Messages to be submitted at end of turn".into(), + ); + + for steer in &self.rejected_steers { + let wrapped = adaptive_wrap_lines( + steer.lines().map(|line| Line::from(line.dim())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim())); + } + } + if !self.queued_messages.is_empty() { if !lines.is_empty() { lines.push(Line::from("")); @@ -304,6 +332,9 @@ mod tests { queue .pending_steers .push("Check the last command output.".to_string()); + queue + .rejected_steers + .push("Rejected steer that will be retried.".to_string()); queue .queued_messages .push("Queued follow-up question".to_string()); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap index 12744049fa..77d57c3f48 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap @@ -3,13 +3,16 @@ source: tui/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 52, height: 8 }, + area: Rect { x: 0, y: 0, width: 52, height: 11 }, content: [ "• Messages to be submitted after next tool call ", " (press esc to interrupt and send immediately) ", " ↳ Please continue. ", " ↳ Check the last command output. ", " ", + "• Messages to be submitted at end of turn ", + " ↳ Rejected steer that will be retried. ", + " ", "• Queued follow-up messages ", " ↳ Queued follow-up question ", " ⌥ + ↑ edit last queued message ", @@ -26,9 +29,13 @@ Buffer { x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 40, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4a6ef92865..7a42b5c268 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -765,6 +765,8 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // User messages that tried to steer a non-regular turn and must be retried first. + rejected_steers_queue: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -925,9 +927,11 @@ impl ThreadComposerState { pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, + rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, + task_running: bool, agent_turn_running: bool, } @@ -1810,7 +1814,7 @@ impl ChatWidget { let had_pending_steers = !self.pending_steers.is_empty(); self.refresh_pending_input_preview(); - if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers { + if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { self.maybe_prompt_plan_implementation(); } // Keep this flag for replayed completion events so a subsequent live TurnComplete can @@ -1832,7 +1836,7 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return; } - if !self.queued_user_messages.is_empty() { + if self.has_queued_follow_up_messages() { return; } if self.active_mode_kind() != ModeKind::Plan { @@ -1904,6 +1908,50 @@ impl ChatWidget { }); } + fn has_queued_follow_up_messages(&self) -> bool { + !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() + } + + fn pop_next_queued_user_message(&mut self) -> Option { + if self.rejected_steers_queue.is_empty() { + self.queued_user_messages.pop_front() + } else { + Some(merge_user_messages( + self.rejected_steers_queue.drain(..).collect(), + )) + } + } + + fn pop_latest_queued_user_message(&mut self) -> Option { + self.queued_user_messages + .pop_back() + .or_else(|| self.rejected_steers_queue.pop_back()) + } + + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { + let Some(pending_steer) = self.pending_steers.pop_front() else { + tracing::warn!( + "received active-turn-not-steerable error without a matching pending steer" + ); + return false; + }; + self.rejected_steers_queue + .push_back(pending_steer.user_message); + if !self.bottom_pane.is_task_running() { + // Will drain rejected_steers_queue in case the steer rejection arrives after task completion + self.maybe_send_next_queued_input(); + } + self.refresh_pending_input_preview(); + true + } + + fn handle_steer_rejected_error(&mut self, codex_error_info: &CodexErrorInfo) -> bool { + matches!( + codex_error_info, + CodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { @@ -2268,7 +2316,7 @@ impl ChatWidget { /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() { + if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { return None; } @@ -2280,11 +2328,12 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let mut to_merge: Vec = self - .pending_steers - .drain(..) - .map(|steer| steer.user_message) - .collect(); + let mut to_merge: Vec = self.rejected_steers_queue.drain(..).collect(); + to_merge.extend( + self.pending_steers + .drain(..) + .map(|steer| steer.user_message), + ); to_merge.extend(self.queued_user_messages.drain(..)); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() @@ -2330,14 +2379,17 @@ impl ChatWidget { .iter() .map(|pending| pending.user_message.clone()) .collect(), + rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), + task_running: self.bottom_pane.is_task_running(), agent_turn_running: self.agent_turn_running, }) } pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); if let Some(input_state) = input_state { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; @@ -2369,13 +2421,24 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } - self.pending_steers.clear(); - self.queued_user_messages = input_state.pending_steers; - self.queued_user_messages - .extend(input_state.queued_user_messages); + self.pending_steers = input_state + .pending_steers + .into_iter() + .map(|user_message| PendingSteer { + compare_key: PendingSteerCompareKey { + message: user_message.text.clone(), + image_count: user_message.local_images.len() + + user_message.remote_image_urls.len(), + }, + user_message, + }) + .collect(); + self.rejected_steers_queue = input_state.rejected_steers_queue; + self.queued_user_messages = input_state.queued_user_messages; } else { self.agent_turn_running = false; self.pending_steers.clear(); + self.rejected_steers_queue.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), @@ -2389,6 +2452,10 @@ impl ChatWidget { self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); self.update_task_running_state(); + if restored_task_running && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + self.refresh_terminal_title(); + } self.refresh_pending_input_preview(); self.request_redraw(); } @@ -3693,6 +3760,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -3899,6 +3967,7 @@ impl ChatWidget { plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4087,6 +4156,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4229,9 +4299,9 @@ impl ChatWidget { if key_event.kind == KeyEventKind::Press && self.queued_message_edit_binding.is_press(key_event) - && !self.queued_user_messages.is_empty() + && self.has_queued_follow_up_messages() { - if let Some(user_message) = self.queued_user_messages.pop_back() { + if let Some(user_message) = self.pop_latest_queued_user_message() { self.restore_user_message_to_composer(user_message); self.refresh_pending_input_preview(); self.request_redraw(); @@ -4455,6 +4525,9 @@ impl ChatWidget { } SlashCommand::Compact => { self.clear_token_usage(); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } SlashCommand::Review => { @@ -4960,10 +5033,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() - || self.bottom_pane.is_task_running() - || self.is_review_mode - { + if !self.is_session_configured() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_pending_input_preview(); } else { @@ -4978,12 +5048,6 @@ impl ChatWidget { self.refresh_pending_input_preview(); return; } - if self.is_review_mode { - self.queued_user_messages.push_back(user_message); - self.refresh_pending_input_preview(); - return; - } - let UserMessage { text, local_images, @@ -5388,7 +5452,9 @@ impl ChatWidget { } EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message, .. - }) => self.on_task_complete(last_agent_message, from_replay), + }) => { + self.on_task_complete(last_agent_message, from_replay); + } EventMsg::TokenCount(ev) => { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); @@ -5400,8 +5466,11 @@ impl ChatWidget { message, codex_error_info, }) => { - if let Some(info) = codex_error_info - && let Some(kind) = rate_limit_error_kind(&info) + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_steer_rejected_error(info)) + { + } else if let Some(kind) = codex_error_info.as_ref().and_then(rate_limit_error_kind) { match kind { RateLimitErrorKind::ServerOverloaded => { @@ -5760,7 +5829,7 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } - if let Some(user_message) = self.queued_user_messages.pop_front() { + if let Some(user_message) = self.pop_next_queued_user_message() { self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). @@ -5779,8 +5848,16 @@ impl ChatWidget { .iter() .map(|steer| steer.user_message.text.clone()) .collect(); - self.bottom_pane - .set_pending_input_preview(queued_messages, pending_steers); + let rejected_steers: Vec = self + .rejected_steers_queue + .iter() + .map(|message| message.text.clone()) + .collect(); + self.bottom_pane.set_pending_input_preview( + queued_messages, + pending_steers, + rejected_steers, + ); } pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { @@ -8802,9 +8879,14 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.queued_user_messages + self.rejected_steers_queue .iter() .map(|message| message.text.clone()) + .chain( + self.queued_user_messages + .iter() + .map(|message| message.text.clone()), + ) .collect() } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap new file mode 100644 index 0000000000..de6cfaddaf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- + + + + + + + + + +• Working (0s • esc to interrupt) + +• Messages to be submitted at end of turn + ↳ Steer submitted while /compact was running. + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap index 79c08c42ed..e514585661 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -11,11 +11,11 @@ expression: term.backend().vt100().screen().contents() + • Working (0s • esc to interrupt) -• Queued follow-up messages - ↳ Queued while /review is running. - ⌥ + ↑ edit last queued message +• Messages to be submitted at end of turn + ↳ Steer submitted while /review was running. › Ask Codex to do anything diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ed37fc5e72..65daefc917 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -89,6 +89,7 @@ use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::McpStartupCompleteEvent; use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; @@ -1536,6 +1537,131 @@ async fn entered_review_mode_defaults_to_current_changes_banner() { assert!(chat.is_review_mode); } +#[tokio::test] +async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "feature".to_string(), + }, + user_facing_hint: Some("feature branch".to_string()), + }), + }); + let _ = drain_insert_history(&mut rx); + chat.queued_user_messages + .push_back(UserMessage::from("queued later")); + + chat.submit_user_message(UserMessage::from("review follow-up one")); + chat.submit_user_message(UserMessage::from("review follow-up two")); + + assert_eq!(chat.pending_steers.len(), 2); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up one".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn steer submit, got {other:?}"), + } + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up two".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected second running-turn steer submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "steer-rejected-1".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + chat.handle_codex_event(Event { + id: "steer-rejected-2".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec![ + "review follow-up one", + "review follow-up two", + "queued later" + ] + ); + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.handle_codex_event(Event { + id: "review-exit".into(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: None, + }), + }); + chat.handle_codex_event(Event { + id: "turn-complete".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up one\nreview follow-up two".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "turn-complete-2".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-2".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued later".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued draft submit after rejected steers, got {other:?}"), + } +} + #[tokio::test] async fn live_agent_message_renders_during_review_mode() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -1915,6 +2041,7 @@ async fn make_chatwidget_manual( show_welcome_banner: true, startup_tooltip_override: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), @@ -3707,9 +3834,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { chat.restore_thread_input_state(Some(ThreadInputState { composer: None, pending_steers: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), queued_user_messages: VecDeque::new(), current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), + task_running: true, agent_turn_running: true, })); @@ -3724,6 +3853,38 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn restore_thread_input_state_restores_pending_steers_without_downgrading_them() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let mut pending_steers = VecDeque::new(); + pending_steers.push_back(UserMessage::from("pending steer")); + let mut rejected_steers_queue = VecDeque::new(); + rejected_steers_queue.push_back(UserMessage::from("already rejected")); + let mut queued_user_messages = VecDeque::new(); + queued_user_messages.push_back(UserMessage::from("queued draft")); + + chat.restore_thread_input_state(Some(ThreadInputState { + composer: None, + pending_steers, + rejected_steers_queue, + queued_user_messages, + current_collaboration_mode: chat.current_collaboration_mode.clone(), + active_collaboration_mask: chat.active_collaboration_mask.clone(), + task_running: false, + agent_turn_running: false, + })); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["already rejected", "queued draft"] + ); + assert_eq!(chat.pending_steers.len(), 1); + assert_eq!( + chat.pending_steers.front().unwrap().user_message.text, + "pending steer" + ); +} + #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -4128,6 +4289,97 @@ async fn steer_enter_queues_while_plan_stream_is_active() { assert!(drain_insert_history(&mut rx).is_empty()); } +#[tokio::test] +async fn submit_user_message_queues_while_compaction_turn_is_running() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-started".to_string(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + chat.submit_user_message(UserMessage::from("queued while compacting")); + + assert_eq!(chat.pending_steers.len(), 1); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while compacting".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn compact steer submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a compact turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }), + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec!["queued while compacting"] + ); + + chat.handle_codex_event(Event { + id: "turn-complete".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while compacting".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued compact follow-up Op::UserTurn, got {other:?}"), + } +} + +#[tokio::test] +async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Compact); + + assert!(chat.bottom_pane.is_task_running()); + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::Compact)) => {} + other => panic!("expected compact op to be submitted, got {other:?}"), + } + + chat.bottom_pane.set_composer_text( + "queued before compact turn start".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(chat.pending_steers.is_empty()); + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "queued before compact turn start" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -11568,9 +11820,17 @@ async fn chatwidget_tall() { } #[tokio::test] -async fn enter_queues_user_messages_while_review_is_running() { +async fn enter_submits_steer_while_review_is_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); chat.handle_codex_event(Event { id: "review-1".into(), @@ -11582,19 +11842,28 @@ async fn enter_queues_user_messages_while_review_is_running() { let _ = drain_insert_history(&mut rx); chat.bottom_pane.set_composer_text( - "Queued while /review is running.".to_string(), + "Steer submitted while /review was running.".to_string(), Vec::new(), Vec::new(), ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.queued_user_messages.len(), 1); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.pending_steers.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "Queued while /review is running." + chat.pending_steers.front().unwrap().user_message.text, + "Steer submitted while /review was running." ); - assert!(chat.pending_steers.is_empty()); - assert_no_submit_op(&mut op_rx); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "Steer submitted while /review was running.".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn steer submit, got {other:?}"), + } assert!(drain_insert_history(&mut rx).is_empty()); } @@ -11602,6 +11871,14 @@ async fn enter_queues_user_messages_while_review_is_running() { async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); chat.handle_codex_event(Event { id: "review-1".into(), @@ -11612,9 +11889,57 @@ async fn review_queues_user_messages_snapshot() { }); let _ = drain_insert_history(&mut rx); - chat.queue_user_message(UserMessage::from( - "Queued while /review is running.".to_string(), + chat.submit_user_message(UserMessage::from( + "Steer submitted while /review was running.".to_string(), )); + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + + let width: u16 = 80; + let height: u16 = 18; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!(term.backend().vt100().screen().contents()); +} + +#[tokio::test] +async fn compact_queues_user_messages_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + chat.submit_user_message(UserMessage::from( + "Steer submitted while /compact was running.".to_string(), + )); + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a compact turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }), + }), + }); let width: u16 = 80; let height: u16 = 18; diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 79c15c29ae..e00a9604f1 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -48,7 +48,9 @@ use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; @@ -63,6 +65,7 @@ use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -967,6 +970,18 @@ fn normalize_harness_overrides_for_cwd( Ok(overrides) } +fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option { + let TypedRequestError::Server { source, .. } = error else { + return None; + }; + let turn_error: AppServerTurnError = serde_json::from_value(source.data.clone()?).ok()?; + matches!( + turn_error.codex_error_info, + Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. }) + ) + .then_some(turn_error) +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -1950,9 +1965,21 @@ impl App { personality, } => { if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { - app_server + match app_server .turn_steer(thread_id, turn_id, items.to_vec()) - .await?; + .await + { + Ok(_) => {} + Err(error) => { + if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) { + if !self.chat_widget.enqueue_rejected_steer() { + self.chat_widget.add_error_message(turn_error.message); + } + } else { + return Err(error.into()); + } + } + } } else { app_server .turn_start( @@ -5170,10 +5197,12 @@ mod tests { use codex_app_server_protocol::AgentMessageDeltaNotification; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol; use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment; use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction; + use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind; use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -5186,6 +5215,7 @@ mod tests { use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; + use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as AppServerUserInput; @@ -5735,7 +5765,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5785,7 +5815,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5834,7 +5864,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5881,7 +5911,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5951,7 +5981,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -8017,6 +8047,30 @@ guardian_approval = true ); } + #[test] + fn active_turn_not_steerable_turn_error_extracts_structured_server_error() { + let turn_error = AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: AppServerNonSteerableTurnKind::Review, + }), + additional_details: None, + }; + let error = TypedRequestError::Server { + method: "turn/steer".to_string(), + source: JSONRPCErrorError { + code: -32602, + message: turn_error.message.clone(), + data: Some(serde_json::to_value(&turn_error).expect("turn error should serialize")), + }, + }; + + assert_eq!( + active_turn_not_steerable_turn_error(&error), + Some(turn_error) + ); + } + #[test] fn select_model_availability_nux_uses_existing_model_order_as_priority() { let mut presets = all_model_presets(); diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index c8a24acff4..0da85bc617 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -1,6 +1,7 @@ use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; @@ -429,7 +430,7 @@ impl AppServerSession { thread_id: ThreadId, turn_id: String, items: Vec, - ) -> Result { + ) -> std::result::Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::TurnSteer { @@ -441,7 +442,6 @@ impl AppServerSession { }, }) .await - .wrap_err("turn/steer failed in app-server TUI") } pub(crate) async fn thread_set_name( diff --git a/codex-rs/tui_app_server/src/bottom_pane/mod.rs b/codex-rs/tui_app_server/src/bottom_pane/mod.rs index 2531f8586b..dd90bc11bd 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/mod.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/mod.rs @@ -821,8 +821,10 @@ impl BottomPane { &mut self, queued: Vec, pending_steers: Vec, + rejected_steers: Vec, ) { self.pending_input_preview.pending_steers = pending_steers; + self.pending_input_preview.rejected_steers = rejected_steers; self.pending_input_preview.queued_messages = queued; self.request_redraw(); } @@ -1143,7 +1145,8 @@ impl BottomPane { } let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty(); let has_pending_input = !self.pending_input_preview.queued_messages.is_empty() - || !self.pending_input_preview.pending_steers.is_empty(); + || !self.pending_input_preview.pending_steers.is_empty() + || !self.pending_input_preview.rejected_steers.is_empty(); let has_status_or_footer = self.status.is_some() || !self.unified_exec_footer.is_empty(); let has_inline_previews = has_pending_thread_approvals || has_pending_input; @@ -1546,7 +1549,11 @@ mod tests { StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); @@ -1573,7 +1580,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); pane.hide_status_indicator(); let width = 48; @@ -1601,7 +1612,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); diff --git a/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs index 1f38a17773..b9690c28ee 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs @@ -10,17 +10,19 @@ use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; -/// Widget that displays pending steers plus user messages queued while a turn is in progress. +/// Widget that displays pending steers plus follow-up messages held while a turn is in progress. /// -/// The widget renders pending steers first, then queued user messages, as two -/// labeled sections. Pending steers explain that they will be submitted after -/// the next tool/result boundary unless the user presses Esc to interrupt and -/// send them immediately. The edit hint at the bottom only appears when there -/// are actual queued user messages to pop 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). +/// The widget renders pending steers first, then rejected steers that will be +/// resubmitted at end of turn, then ordinary queued user messages. Pending +/// steers explain that they will be submitted after the next tool/result +/// boundary unless the user presses Esc to interrupt and send them +/// immediately. The edit hint at the bottom only appears when there are actual +/// queued user messages to pop 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 PendingInputPreview { pub pending_steers: Vec, + pub rejected_steers: Vec, pub queued_messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. @@ -33,6 +35,7 @@ impl PendingInputPreview { pub(crate) fn new() -> Self { Self { pending_steers: Vec::new(), + rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: key_hint::alt(KeyCode::Up), } @@ -67,7 +70,11 @@ impl PendingInputPreview { } fn as_renderable(&self, width: u16) -> Box { - if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 { + if (self.pending_steers.is_empty() + && self.rejected_steers.is_empty() + && self.queued_messages.is_empty()) + || width < 4 + { return Box::new(()); } @@ -96,6 +103,27 @@ impl PendingInputPreview { } } + if !self.rejected_steers.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + Self::push_section_header( + &mut lines, + width, + "Messages to be submitted at end of turn".into(), + ); + + for steer in &self.rejected_steers { + let wrapped = adaptive_wrap_lines( + steer.lines().map(|line| Line::from(line.dim())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim())); + } + } + if !self.queued_messages.is_empty() { if !lines.is_empty() { lines.push(Line::from("")); @@ -304,6 +332,9 @@ mod tests { queue .pending_steers .push("Check the last command output.".to_string()); + queue + .rejected_steers + .push("Rejected steer that will be retried.".to_string()); queue .queued_messages .push("Queued follow-up question".to_string()); diff --git a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap index 0f0d1eabd3..16da87fb91 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap +++ b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap @@ -3,13 +3,16 @@ source: tui_app_server/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 52, height: 8 }, + area: Rect { x: 0, y: 0, width: 52, height: 11 }, content: [ "• Messages to be submitted after next tool call ", " (press esc to interrupt and send immediately) ", " ↳ Please continue. ", " ↳ Check the last command output. ", " ", + "• Messages to be submitted at end of turn ", + " ↳ Rejected steer that will be retried. ", + " ", "• Queued follow-up messages ", " ↳ Queued follow-up question ", " ⌥ + ↑ edit last queued message ", @@ -26,9 +29,13 @@ Buffer { x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 40, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 78fcc84e5e..ad8b9e709e 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -807,6 +807,8 @@ pub(crate) struct ChatWidget { suppress_initial_user_message_submit: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // User messages that tried to steer a non-regular turn and must be retried first. + rejected_steers_queue: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -958,9 +960,11 @@ impl ThreadComposerState { pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, + rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, + task_running: bool, agent_turn_running: bool, } @@ -2172,7 +2176,7 @@ impl ChatWidget { let had_pending_steers = !self.pending_steers.is_empty(); self.refresh_pending_input_preview(); - if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers { + if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { self.maybe_prompt_plan_implementation(); } // Keep this flag for replayed completion events so a subsequent live TurnComplete can @@ -2194,7 +2198,7 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return; } - if !self.queued_user_messages.is_empty() { + if self.has_queued_follow_up_messages() { return; } if self.active_mode_kind() != ModeKind::Plan { @@ -2266,6 +2270,57 @@ impl ChatWidget { }); } + fn has_queued_follow_up_messages(&self) -> bool { + !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() + } + + fn pop_next_queued_user_message(&mut self) -> Option { + if self.rejected_steers_queue.is_empty() { + self.queued_user_messages.pop_front() + } else { + Some(merge_user_messages( + self.rejected_steers_queue.drain(..).collect(), + )) + } + } + + fn pop_latest_queued_user_message(&mut self) -> Option { + self.queued_user_messages + .pop_back() + .or_else(|| self.rejected_steers_queue.pop_back()) + } + + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { + let Some(pending_steer) = self.pending_steers.pop_front() else { + tracing::warn!( + "received active-turn-not-steerable error without a matching pending steer" + ); + return false; + }; + self.rejected_steers_queue + .push_back(pending_steer.user_message); + self.refresh_pending_input_preview(); + true + } + + #[cfg(test)] + fn handle_steer_rejected_error(&mut self, codex_error_info: &CoreCodexErrorInfo) -> bool { + matches!( + codex_error_info, + CoreCodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + + fn handle_app_server_steer_rejected_error( + &mut self, + codex_error_info: &AppServerCodexErrorInfo, + ) -> bool { + matches!( + codex_error_info, + AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { @@ -2508,7 +2563,11 @@ impl ChatWidget { message: String, codex_error_info: Option, ) { - if let Some(info) = codex_error_info + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) + { + } else if let Some(info) = codex_error_info .as_ref() .and_then(app_server_rate_limit_error_kind) { @@ -2648,7 +2707,7 @@ impl ChatWidget { /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() { + if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { return None; } @@ -2660,11 +2719,12 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let mut to_merge: Vec = self - .pending_steers - .drain(..) - .map(|steer| steer.user_message) - .collect(); + let mut to_merge: Vec = self.rejected_steers_queue.drain(..).collect(); + to_merge.extend( + self.pending_steers + .drain(..) + .map(|steer| steer.user_message), + ); to_merge.extend(self.queued_user_messages.drain(..)); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() @@ -2710,14 +2770,17 @@ impl ChatWidget { .iter() .map(|pending| pending.user_message.clone()) .collect(), + rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), + task_running: self.bottom_pane.is_task_running(), agent_turn_running: self.agent_turn_running, }) } pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); if let Some(input_state) = input_state { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; @@ -2749,13 +2812,24 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } - self.pending_steers.clear(); - self.queued_user_messages = input_state.pending_steers; - self.queued_user_messages - .extend(input_state.queued_user_messages); + self.pending_steers = input_state + .pending_steers + .into_iter() + .map(|user_message| PendingSteer { + compare_key: PendingSteerCompareKey { + message: user_message.text.clone(), + image_count: user_message.local_images.len() + + user_message.remote_image_urls.len(), + }, + user_message, + }) + .collect(); + self.rejected_steers_queue = input_state.rejected_steers_queue; + self.queued_user_messages = input_state.queued_user_messages; } else { self.agent_turn_running = false; self.pending_steers.clear(); + self.rejected_steers_queue.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), @@ -2769,6 +2843,9 @@ impl ChatWidget { self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); self.update_task_running_state(); + if restored_task_running && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.refresh_pending_input_preview(); self.request_redraw(); } @@ -4244,6 +4321,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4381,9 +4459,9 @@ impl ChatWidget { if key_event.kind == KeyEventKind::Press && self.queued_message_edit_binding.is_press(key_event) - && !self.queued_user_messages.is_empty() + && self.has_queued_follow_up_messages() { - if let Some(user_message) = self.queued_user_messages.pop_back() { + if let Some(user_message) = self.pop_latest_queued_user_message() { self.restore_user_message_to_composer(user_message); self.refresh_pending_input_preview(); self.request_redraw(); @@ -4607,6 +4685,9 @@ impl ChatWidget { } SlashCommand::Compact => { self.clear_token_usage(); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.app_event_tx.compact(); } SlashCommand::Review => { @@ -5099,10 +5180,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() - || self.bottom_pane.is_task_running() - || self.is_review_mode - { + if !self.is_session_configured() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_pending_input_preview(); } else { @@ -5117,12 +5195,6 @@ impl ChatWidget { self.refresh_pending_input_preview(); return; } - if self.is_review_mode { - self.queued_user_messages.push_back(user_message); - self.refresh_pending_input_preview(); - return; - } - let UserMessage { text, local_images, @@ -6406,7 +6478,9 @@ impl ChatWidget { } EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message, .. - }) => self.on_task_complete(last_agent_message, from_replay), + }) => { + self.on_task_complete(last_agent_message, from_replay); + } EventMsg::TokenCount(ev) => { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); @@ -6418,8 +6492,13 @@ impl ChatWidget { message, codex_error_info, }) => { - if let Some(info) = codex_error_info - && let Some(kind) = core_rate_limit_error_kind(&info) + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_steer_rejected_error(info)) + { + } else if let Some(kind) = codex_error_info + .as_ref() + .and_then(core_rate_limit_error_kind) { match kind { RateLimitErrorKind::ServerOverloaded => { @@ -6784,7 +6863,7 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } - if let Some(user_message) = self.queued_user_messages.pop_front() { + if let Some(user_message) = self.pop_next_queued_user_message() { self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). @@ -6803,8 +6882,16 @@ impl ChatWidget { .iter() .map(|steer| steer.user_message.text.clone()) .collect(); - self.bottom_pane - .set_pending_input_preview(queued_messages, pending_steers); + let rejected_steers: Vec = self + .rejected_steers_queue + .iter() + .map(|message| message.text.clone()) + .collect(); + self.bottom_pane.set_pending_input_preview( + queued_messages, + pending_steers, + rejected_steers, + ); } pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { @@ -10035,9 +10122,14 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.queued_user_messages + self.rejected_steers_queue .iter() .map(|message| message.text.clone()) + .chain( + self.queued_user_messages + .iter() + .map(|message| message.text.clone()), + ) .collect() } diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap new file mode 100644 index 0000000000..65f7845183 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap @@ -0,0 +1,21 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- + + + + + + + + + +• Working (0s • esc to interrupt) + +• Messages to be submitted at end of turn + ↳ Steer submitted while /compact was running. + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap index 3985a1dc2c..ad7a7ed279 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -11,11 +11,11 @@ expression: term.backend().vt100().screen().contents() + • Working (0s • esc to interrupt) -• Queued follow-up messages - ↳ Queued while /review is running. - ⌥ + ↑ edit last queued message +• Messages to be submitted at end of turn + ↳ Steer submitted while /review was running. › Ask Codex to do anything diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 31e94249a7..e8173a43ff 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -112,6 +112,7 @@ use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::McpStartupCompleteEvent; use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; @@ -1560,6 +1561,131 @@ async fn entered_review_mode_defaults_to_current_changes_banner() { assert!(chat.is_review_mode); } +#[tokio::test] +async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "feature".to_string(), + }, + user_facing_hint: Some("feature branch".to_string()), + }), + }); + let _ = drain_insert_history(&mut rx); + chat.queued_user_messages + .push_back(UserMessage::from("queued later")); + + chat.submit_user_message(UserMessage::from("review follow-up one")); + chat.submit_user_message(UserMessage::from("review follow-up two")); + + assert_eq!(chat.pending_steers.len(), 2); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up one".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn steer submit, got {other:?}"), + } + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up two".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected second running-turn steer submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "steer-rejected-1".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + chat.handle_codex_event(Event { + id: "steer-rejected-2".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec![ + "review follow-up one", + "review follow-up two", + "queued later" + ] + ); + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.handle_codex_event(Event { + id: "review-exit".into(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: None, + }), + }); + chat.handle_codex_event(Event { + id: "turn-complete".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up one\nreview follow-up two".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "turn-complete-2".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-2".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued later".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued draft submit after rejected steers, got {other:?}"), + } +} + #[tokio::test] async fn live_agent_message_renders_during_review_mode() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -1934,6 +2060,7 @@ async fn make_chatwidget_manual( show_welcome_banner: true, startup_tooltip_override: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), @@ -3716,9 +3843,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { chat.restore_thread_input_state(Some(ThreadInputState { composer: None, pending_steers: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), queued_user_messages: VecDeque::new(), current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), + task_running: true, agent_turn_running: true, })); @@ -3733,6 +3862,38 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn restore_thread_input_state_restores_pending_steers_without_downgrading_them() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let mut pending_steers = VecDeque::new(); + pending_steers.push_back(UserMessage::from("pending steer")); + let mut rejected_steers_queue = VecDeque::new(); + rejected_steers_queue.push_back(UserMessage::from("already rejected")); + let mut queued_user_messages = VecDeque::new(); + queued_user_messages.push_back(UserMessage::from("queued draft")); + + chat.restore_thread_input_state(Some(ThreadInputState { + composer: None, + pending_steers, + rejected_steers_queue, + queued_user_messages, + current_collaboration_mode: chat.current_collaboration_mode.clone(), + active_collaboration_mask: chat.active_collaboration_mask.clone(), + task_running: false, + agent_turn_running: false, + })); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["already rejected", "queued draft"] + ); + assert_eq!(chat.pending_steers.len(), 1); + assert_eq!( + chat.pending_steers.front().unwrap().user_message.text, + "pending steer" + ); +} + #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -4131,6 +4292,107 @@ async fn steer_enter_queues_while_plan_stream_is_active() { assert!(drain_insert_history(&mut rx).is_empty()); } +#[tokio::test] +async fn submit_user_message_queues_while_compaction_turn_is_running() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + }, + }), + None, + ); + + chat.submit_user_message(UserMessage::from("queued while compacting")); + + assert_eq!(chat.pending_steers.len(), 1); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while compacting".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn compact steer submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a compact turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }), + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec!["queued while compacting"] + ); + + chat.handle_server_notification( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::Completed, + error: None, + }, + }), + None, + ); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while compacting".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued compact follow-up Op::UserTurn, got {other:?}"), + } +} + +#[tokio::test] +async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Compact); + + assert!(chat.bottom_pane.is_task_running()); + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::Compact)) => {} + other => panic!("expected compact op to be submitted, got {other:?}"), + } + + chat.bottom_pane.set_composer_text( + "queued before compact turn start".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(chat.pending_steers.is_empty()); + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "queued before compact turn start" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -11971,9 +12233,17 @@ async fn chatwidget_tall() { } #[tokio::test] -async fn enter_queues_user_messages_while_review_is_running() { +async fn enter_submits_steer_while_review_is_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); chat.handle_codex_event(Event { id: "review-1".into(), @@ -11985,19 +12255,28 @@ async fn enter_queues_user_messages_while_review_is_running() { let _ = drain_insert_history(&mut rx); chat.bottom_pane.set_composer_text( - "Queued while /review is running.".to_string(), + "Steer submitted while /review was running.".to_string(), Vec::new(), Vec::new(), ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.queued_user_messages.len(), 1); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.pending_steers.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "Queued while /review is running." + chat.pending_steers.front().unwrap().user_message.text, + "Steer submitted while /review was running." ); - assert!(chat.pending_steers.is_empty()); - assert_no_submit_op(&mut op_rx); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "Steer submitted while /review was running.".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn steer submit, got {other:?}"), + } assert!(drain_insert_history(&mut rx).is_empty()); } @@ -12005,6 +12284,14 @@ async fn enter_queues_user_messages_while_review_is_running() { async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); chat.handle_codex_event(Event { id: "review-1".into(), @@ -12015,9 +12302,57 @@ async fn review_queues_user_messages_snapshot() { }); let _ = drain_insert_history(&mut rx); - chat.queue_user_message(UserMessage::from( - "Queued while /review is running.".to_string(), + chat.submit_user_message(UserMessage::from( + "Steer submitted while /review was running.".to_string(), )); + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + + let width: u16 = 80; + let height: u16 = 18; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!(term.backend().vt100().screen().contents()); +} + +#[tokio::test] +async fn compact_queues_user_messages_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + chat.submit_user_message(UserMessage::from( + "Steer submitted while /compact was running.".to_string(), + )); + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a compact turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }), + }), + }); let width: u16 = 80; let height: u16 = 18; From 37ac0c093cb4be42f7812737366cab181b9d0417 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 23 Mar 2026 18:53:54 +0000 Subject: [PATCH 17/38] feat: structured multi-agent output (#15515) Send input now sends messages as assistant message and with this format: ``` author: /root/worker_a recipient: /root/worker_a/tester other_recipients: [] Content: bla bla bla. Actual content. Only text for now ``` --- codex-rs/core/src/agent/control.rs | 76 +++- codex-rs/core/src/agent/control_tests.rs | 55 +++ .../core/src/agent/inter_agent_instruction.rs | 74 ++++ codex-rs/core/src/agent/mod.rs | 1 + codex-rs/core/src/codex.rs | 81 +++-- .../core/src/codex/rollout_reconstruction.rs | 10 +- .../src/codex/rollout_reconstruction_tests.rs | 102 ++++++ codex-rs/core/src/codex_tests.rs | 28 ++ codex-rs/core/src/codex_thread.rs | 101 +++++- codex-rs/core/src/context_manager/history.rs | 15 +- .../core/src/context_manager/history_tests.rs | 41 +++ codex-rs/core/src/event_mapping.rs | 2 +- codex-rs/core/src/event_mapping_tests.rs | 37 ++ codex-rs/core/src/tasks/mod.rs | 30 ++ codex-rs/core/src/thread_manager.rs | 27 ++ .../tools/handlers/multi_agents/send_input.rs | 48 ++- .../src/tools/handlers/multi_agents_tests.rs | 332 ++++++++++++++++++ 17 files changed, 994 insertions(+), 66 deletions(-) create mode 100644 codex-rs/core/src/agent/inter_agent_instruction.rs diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index d2a7cbf9db..5bc5a0711c 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1,4 +1,6 @@ use crate::agent::AgentStatus; +use crate::agent::inter_agent_instruction::InterAgentDelivery; +use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::agent::registry::AgentMetadata; use crate::agent::registry::AgentRegistry; use crate::agent::role::DEFAULT_ROLE_NAME; @@ -454,20 +456,53 @@ impl AgentControl { items: Vec, ) -> CodexResult { let state = self.upgrade()?; - let result = state - .send_op( - agent_id, - Op::UserInput { - items, - final_output_json_schema: None, - }, - ) - .await; - if matches!(result, Err(CodexErr::InternalAgentDied)) { - let _ = state.remove_thread(&agent_id).await; - self.state.release_spawned_thread(agent_id); - } - result + self.handle_thread_request_result( + agent_id, + &state, + state + .send_op( + agent_id, + Op::UserInput { + items, + final_output_json_schema: None, + }, + ) + .await, + ) + .await + } + + /// Append a prebuilt message to an existing agent thread outside the normal user-input path. + #[cfg(test)] + pub(crate) async fn append_message( + &self, + agent_id: ThreadId, + message: ResponseItem, + ) -> CodexResult { + let state = self.upgrade()?; + self.handle_thread_request_result( + agent_id, + &state, + state.append_message(agent_id, message).await, + ) + .await + } + + pub(crate) async fn deliver_inter_agent_instruction( + &self, + agent_id: ThreadId, + instruction: InterAgentInstruction, + delivery: InterAgentDelivery, + ) -> CodexResult { + let state = self.upgrade()?; + self.handle_thread_request_result( + agent_id, + &state, + state + .deliver_inter_agent_instruction(agent_id, instruction, delivery) + .await, + ) + .await } /// Interrupt the current task for an existing agent thread. @@ -476,6 +511,19 @@ impl AgentControl { state.send_op(agent_id, Op::Interrupt).await } + async fn handle_thread_request_result( + &self, + agent_id: ThreadId, + state: &Arc, + result: CodexResult, + ) -> CodexResult { + if matches!(result, Err(CodexErr::InternalAgentDied)) { + let _ = state.remove_thread(&agent_id).await; + self.state.release_spawned_thread(agent_id); + } + result + } + /// Submit a shutdown request for a live agent without marking it explicitly closed in /// persisted spawn-edge state. pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 20c051f853..677bf5a2f3 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -382,6 +382,61 @@ async fn send_input_submits_user_message() { assert_eq!(captured, Some(expected)); } +#[tokio::test] +async fn append_message_records_assistant_message() { + let harness = AgentControlHarness::new().await; + let (thread_id, thread) = harness.start_thread().await; + let message = + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: hello from tests"; + + let submission_id = harness + .control + .append_message( + thread_id, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::InputText { + text: message.to_string(), + }], + end_turn: None, + phase: None, + }, + ) + .await + .expect("append_message should succeed"); + assert!(!submission_id.is_empty()); + + timeout(Duration::from_secs(5), async { + loop { + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let recorded = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "assistant" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } if text == message + )) + ) + }); + if recorded { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("assistant message should be recorded"); +} + #[tokio::test] async fn spawn_agent_creates_thread_and_sends_prompt() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/agent/inter_agent_instruction.rs b/codex-rs/core/src/agent/inter_agent_instruction.rs new file mode 100644 index 0000000000..0eff40beb4 --- /dev/null +++ b/codex-rs/core/src/agent/inter_agent_instruction.rs @@ -0,0 +1,74 @@ +use codex_protocol::AgentPath; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InterAgentDelivery { + CurrentTurn, + NextTurn, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct InterAgentInstruction { + author: AgentPath, + recipient: AgentPath, + other_recipients: Vec, + content: String, +} + +impl InterAgentInstruction { + pub(crate) fn new( + author: AgentPath, + recipient: AgentPath, + other_recipients: Vec, + content: String, + ) -> Self { + Self { + author, + recipient, + other_recipients, + content, + } + } + + pub(crate) fn to_response_item(&self) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: self.as_text(), + }], + end_turn: None, + phase: None, + } + } + + pub(crate) fn is_message_content(content: &[ContentItem]) -> bool { + content.iter().any(|content_item| match content_item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + Self::is_instruction_text(text) + } + _ => false, + }) + } + + fn as_text(&self) -> String { + let other_recipients = self + .other_recipients + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(", "); + format!( + "author: {}\nrecipient: {}\nother_recipients: [{other_recipients}]\nContent: {}", + self.author, self.recipient, self.content + ) + } + + fn is_instruction_text(text: &str) -> bool { + text.starts_with("author: ") + && text.contains("\nrecipient: ") + && text.contains("\nother_recipients: [") + && text.contains("]\nContent: ") + } +} diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 350962dc08..3f14e5c590 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod agent_resolver; pub(crate) mod control; +pub(crate) mod inter_agent_instruction; mod registry; pub(crate) mod role; pub(crate) mod status; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 099edb7c0f..b040883c25 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -798,6 +798,7 @@ pub(crate) struct Session { pending_mcp_server_refresh_config: Mutex>, pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, + idle_pending_input: Mutex>, pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, js_repl: Arc, @@ -809,6 +810,7 @@ pub(crate) struct TurnSkillsContext { pub(crate) outcome: Arc, pub(crate) implicit_invocation_seen_skills: Arc>>, } + impl TurnSkillsContext { pub(crate) fn new(outcome: Arc) -> Self { Self { @@ -1895,6 +1897,7 @@ impl Session { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: GuardianReviewSessionManager::default(), services, js_repl, @@ -3912,7 +3915,7 @@ impl Session { Ok(active_turn_id.clone()) } - /// Returns the input if there was no task running to inject into + /// Returns the input if there was no task running to inject into. pub async fn inject_response_items( &self, input: Vec, @@ -3953,6 +3956,24 @@ impl Session { } } + /// Queue response items to be injected into the next active turn created for this session. + pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec) { + if items.is_empty() { + return; + } + + let mut idle_pending_input = self.idle_pending_input.lock().await; + idle_pending_input.extend(items); + } + + pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec { + std::mem::take(&mut *self.idle_pending_input.lock().await) + } + + pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool { + !self.idle_pending_input.lock().await.is_empty() + } + pub async fn has_pending_input(&self) -> bool { let active = self.active_turn.lock().await; match active.as_ref() { @@ -5441,7 +5462,7 @@ pub(crate) async fn run_turn( prewarmed_client_session: Option, cancellation_token: CancellationToken, ) -> Option { - if input.is_empty() { + if input.is_empty() && !sess.has_pending_input().await { return None; } @@ -5583,25 +5604,33 @@ pub(crate) async fn run_turn( }) .collect::>(); - let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone()); - let response_item: ResponseItem = initial_input_for_turn.clone().into(); - let mut last_agent_message: Option = None; if run_pending_session_start_hooks(&sess, &turn_context).await { - return last_agent_message; + return None; } - let user_prompt_submit_outcome = - run_user_prompt_submit_hooks(&sess, &turn_context, UserMessageItem::new(&input).message()) - .await; - if user_prompt_submit_outcome.should_stop { - record_additional_contexts( + let additional_contexts = if input.is_empty() { + Vec::new() + } else { + let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone()); + let response_item: ResponseItem = initial_input_for_turn.clone().into(); + let user_prompt_submit_outcome = run_user_prompt_submit_hooks( &sess, &turn_context, - user_prompt_submit_outcome.additional_contexts, + UserMessageItem::new(&input).message(), ) .await; - return last_agent_message; - } - let additional_contexts = user_prompt_submit_outcome.additional_contexts; + if user_prompt_submit_outcome.should_stop { + record_additional_contexts( + &sess, + &turn_context, + user_prompt_submit_outcome.additional_contexts, + ) + .await; + return None; + } + sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item) + .await; + user_prompt_submit_outcome.additional_contexts + }; sess.services .analytics_events_client .track_app_mentioned(tracking.clone(), mentioned_app_invocations); @@ -5612,17 +5641,17 @@ pub(crate) async fn run_turn( } sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; - sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item) - .await; record_additional_contexts(&sess, &turn_context, additional_contexts).await; - // Track the previous-turn baseline from the regular user-turn path only so - // standalone tasks (compact/shell/review/undo) cannot suppress future - // model/realtime injections. - sess.set_previous_turn_settings(Some(PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), - realtime_active: Some(turn_context.realtime_active), - })) - .await; + if !input.is_empty() { + // Track the previous-turn baseline from the regular user-turn path only so + // standalone tasks (compact/shell/review/undo) cannot suppress future + // model/realtime injections. + sess.set_previous_turn_settings(Some(PreviousTurnSettings { + model: turn_context.model_info.slug.clone(), + realtime_active: Some(turn_context.realtime_active), + })) + .await; + } if !skill_items.is_empty() { sess.record_conversation_items(&turn_context, &skill_items) @@ -5633,8 +5662,10 @@ pub(crate) async fn run_turn( .await; } + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; + let mut last_agent_message: Option = None; let mut stop_hook_active = false; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. diff --git a/codex-rs/core/src/codex/rollout_reconstruction.rs b/codex-rs/core/src/codex/rollout_reconstruction.rs index 11ddbc1928..a4c042af0c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction.rs @@ -1,4 +1,5 @@ use super::*; +use crate::context_manager::is_user_turn_boundary; // Return value of `Session::reconstruct_history_from_rollout`, bundling the rebuilt history with // the resume/fork hydration metadata derived from the same replay. @@ -201,9 +202,12 @@ impl Session { ); } } - RolloutItem::ResponseItem(_) - | RolloutItem::EventMsg(_) - | RolloutItem::SessionMeta(_) => {} + RolloutItem::ResponseItem(response_item) => { + let active_segment = + active_segment.get_or_insert_with(ActiveReplaySegment::default); + active_segment.counts_as_user_turn |= is_user_turn_boundary(response_item); + } + RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => {} } if base_replacement_history.is_some() diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 6cc99a2907..09b4f45613 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -33,6 +33,18 @@ fn assistant_message(text: &str) -> ResponseItem { } } +fn inter_agent_assistant_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } +} + #[tokio::test] async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previous_turn_settings() { @@ -430,6 +442,96 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad ); } +#[tokio::test] +async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { + let (session, turn_context) = make_session_and_context().await; + let first_context_item = turn_context.to_turn_context_item(); + let first_turn_id = first_context_item + .turn_id + .clone() + .expect("turn context should have turn_id"); + let assistant_turn_id = "assistant-instruction-turn".to_string(); + let assistant_turn_context = TurnContextItem { + turn_id: Some(assistant_turn_id.clone()), + ..first_context_item.clone() + }; + let assistant_instruction = inter_agent_assistant_message( + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", + ); + let assistant_reply = assistant_message("worker reply"); + + let rollout_items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: first_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage( + codex_protocol::protocol::UserMessageEvent { + message: "turn 1 user".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + )), + RolloutItem::TurnContext(first_context_item.clone()), + RolloutItem::ResponseItem(user_message("turn 1 user")), + RolloutItem::ResponseItem(assistant_message("turn 1 assistant")), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id: first_turn_id, + last_agent_message: None, + }, + )), + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: assistant_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::TurnContext(assistant_turn_context), + RolloutItem::ResponseItem(assistant_instruction), + RolloutItem::ResponseItem(assistant_reply), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id: assistant_turn_id, + last_agent_message: None, + }, + )), + RolloutItem::EventMsg(EventMsg::ThreadRolledBack( + codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + )), + ]; + + let reconstructed = session + .reconstruct_history_from_rollout(&turn_context, &rollout_items) + .await; + + assert_eq!( + reconstructed.history, + vec![ + user_message("turn 1 user"), + assistant_message("turn 1 assistant") + ] + ); + assert_eq!( + reconstructed.previous_turn_settings, + Some(PreviousTurnSettings { + model: turn_context.model_info.slug.clone(), + realtime_active: Some(turn_context.realtime_active), + }) + ); + assert_eq!( + serde_json::to_value(reconstructed.reference_context_item) + .expect("serialize reconstructed reference context item"), + serde_json::to_value(Some(first_context_item)) + .expect("serialize expected reference context item") + ); +} + #[tokio::test] async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding_user_turns() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 2305cb1fa9..24470051f2 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2682,6 +2682,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, js_repl, @@ -3481,6 +3482,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, js_repl, @@ -4631,6 +4633,32 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() { assert_eq!(sess.get_pending_input().await, vec![later, newer]); } +#[tokio::test] +async fn queued_response_items_for_next_turn_move_into_next_active_turn() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + let queued_item = ResponseInputItem::Message { + role: "assistant".to_string(), + content: vec![ContentItem::InputText { + text: "queued before wake".to_string(), + }], + }; + + sess.queue_response_items_for_next_turn(vec![queued_item.clone()]) + .await; + + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + + assert_eq!(sess.get_pending_input().await, vec![queued_item]); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn abort_review_task_emits_exited_then_aborted_and_records_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index e016fec977..ff36de6c15 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,4 +1,6 @@ use crate::agent::AgentStatus; +use crate::agent::inter_agent_instruction::InterAgentDelivery; +use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; @@ -122,29 +124,90 @@ impl CodexThread { /// Records a user-role session-prefix message without creating a new user turn boundary. pub(crate) async fn inject_user_message_without_turn(&self, message: String) { - let pending_item = ResponseInputItem::Message { + let message = ResponseItem::Message { + id: None, role: "user".to_string(), content: vec![ContentItem::InputText { text: message }], + end_turn: None, + phase: None, }; - let pending_items = vec![pending_item]; - let Err(items_without_active_turn) = self + let pending_item = match pending_message_input_item(&message) { + Ok(pending_item) => pending_item, + Err(err) => { + debug_assert!(false, "session-prefix message append should succeed: {err}"); + return; + } + }; + if self .codex .session - .inject_response_items(pending_items) + .inject_response_items(vec![pending_item]) .await - else { - return; - }; + .is_err() + { + let turn_context = self.codex.session.new_default_turn().await; + self.codex + .session + .record_conversation_items(turn_context.as_ref(), &[message]) + .await; + } + } - let turn_context = self.codex.session.new_default_turn().await; - let items: Vec = items_without_active_turn - .into_iter() - .map(ResponseItem::from) - .collect(); + /// Append a prebuilt message to the thread history without treating it as a user turn. + /// + /// If the thread already has an active turn, the message is queued as pending input for that + /// turn. Otherwise it is queued at session scope and a regular turn is started so the agent + /// can consume that pending input through the normal turn pipeline. + pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult { + let submission_id = uuid::Uuid::new_v4().to_string(); + let pending_item = pending_message_input_item(&message)?; + if let Err(items) = self + .codex + .session + .inject_response_items(vec![pending_item]) + .await + { + self.codex + .session + .queue_response_items_for_next_turn(items) + .await; + self.codex + .session + .ensure_task_for_queued_response_items() + .await; + } + + Ok(submission_id) + } + + pub(crate) async fn deliver_inter_agent_instruction( + &self, + instruction: InterAgentInstruction, + delivery: InterAgentDelivery, + ) -> CodexResult { + let message = instruction.to_response_item(); + match delivery { + InterAgentDelivery::CurrentTurn => self.append_message(message).await, + InterAgentDelivery::NextTurn => self.queue_message_for_next_turn(message).await, + } + } + + /// Queue a prebuilt message so the next turn records it before any submitted user input. + pub(crate) async fn queue_message_for_next_turn( + &self, + message: ResponseItem, + ) -> CodexResult { + let submission_id = uuid::Uuid::new_v4().to_string(); + let pending_item = pending_message_input_item(&message)?; self.codex .session - .record_conversation_items(turn_context.as_ref(), &items) + .queue_response_items_for_next_turn(vec![pending_item]) .await; + self.codex + .session + .ensure_task_for_queued_response_items() + .await; + Ok(submission_id) } pub fn rollout_path(&self) -> Option { @@ -198,3 +261,15 @@ impl CodexThread { Ok(*guard) } } + +fn pending_message_input_item(message: &ResponseItem) -> CodexResult { + match message { + ResponseItem::Message { role, content, .. } => Ok(ResponseInputItem::Message { + role: role.clone(), + content: content.clone(), + }), + _ => Err(CodexErr::InvalidRequest( + "append_message only supports ResponseItem::Message".to_string(), + )), + } +} diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index f990a80dce..6ed0370481 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,3 +1,4 @@ +use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::codex::TurnContext; use crate::context_manager::normalize; use crate::event_mapping::is_contextual_user_message_content; @@ -204,9 +205,10 @@ impl ContextManager { } } - /// Drop the last `num_turns` user turns from this history. + /// Drop the last `num_turns` instruction turns from this history. /// - /// "User turns" are identified as `ResponseItem::Message` entries whose role is `"user"`. + /// Instruction turns are history messages that should behave like a new prompt boundary: + /// ordinary user messages and structured assistant inter-agent instructions. /// /// This mirrors thread-rollback semantics: /// - `num_turns == 0` is a no-op @@ -248,7 +250,7 @@ impl ContextManager { } fn get_non_last_reasoning_items_tokens(&self) -> i64 { - // Get reasoning items excluding all the ones after the last user message. + // Get reasoning items excluding all the ones after the last instruction boundary. let Some(last_user_index) = self.items.iter().rposition(is_user_turn_boundary) else { return 0; }; @@ -631,7 +633,12 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { return false; }; - role == "user" && !is_contextual_user_message_content(content) + (role == "user" && !is_contextual_user_message_content(content)) + || (role == "assistant" && is_inter_agent_instruction_content(content)) +} + +fn is_inter_agent_instruction_content(content: &[ContentItem]) -> bool { + InterAgentInstruction::is_message_content(content) } fn user_message_positions(items: &[ResponseItem]) -> Vec { diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 71b3aded0c..4deb76ed6d 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -38,6 +38,18 @@ fn assistant_msg(text: &str) -> ResponseItem { } } +fn inter_agent_assistant_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } +} + fn create_history_with_items(items: Vec) -> ContextManager { let mut h = ContextManager::new(); // Use a generous but fixed token budget; tests only rely on truncation @@ -225,6 +237,35 @@ fn items_after_last_model_generated_tokens_are_zero_without_model_generated_item ); } +#[test] +fn inter_agent_assistant_messages_are_turn_boundaries() { + let item = inter_agent_assistant_msg( + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", + ); + + assert!(is_user_turn_boundary(&item)); +} + +#[test] +fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() { + let first_turn = user_input_text_msg("first"); + let first_reply = assistant_msg("done"); + let inter_agent_turn = inter_agent_assistant_msg( + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", + ); + let inter_agent_reply = assistant_msg("worker reply"); + let mut history = create_history_with_items(vec![ + first_turn.clone(), + first_reply.clone(), + inter_agent_turn, + inter_agent_reply, + ]); + + history.drop_last_n_user_turns(1); + + assert_eq!(history.raw_items(), &vec![first_turn, first_reply]); +} + #[test] fn total_token_usage_includes_all_items_after_last_model_generated_item() { let mut history = create_history_with_items(vec![assistant_msg("already counted by API")]); diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index ad776d1424..8f80547a4c 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -72,7 +72,7 @@ fn parse_agent_message( let mut content: Vec = Vec::new(); for content_item in message.iter() { match content_item { - ContentItem::OutputText { text } => { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { content.push(AgentMessageContent::Text { text: text.clone() }); } _ => { diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 553550d74a..65c2e7d97d 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -95,6 +95,43 @@ fn skips_local_image_label_text() { } } +#[test] +fn parses_assistant_message_input_text_for_backward_compatibility() { + let item = ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::InputText { + text: "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue" + .to_string(), + }], + end_turn: None, + phase: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected assistant message turn item"); + + match turn_item { + TurnItem::AgentMessage(message) => { + let rendered = message + .content + .into_iter() + .map(|content| { + let AgentMessageContent::Text { text } = content; + text + }) + .collect::>(); + assert_eq!( + rendered, + vec![ + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue" + .to_string() + ] + ); + } + other => panic!("expected TurnItem::AgentMessage, got {other:?}"), + } +} + #[test] fn skips_unnamed_image_label_text() { let image_url = "data:image/png;base64,abc".to_string(); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b8e1d73b71..b2f110486c 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -153,7 +153,15 @@ impl Session { ) { self.abort_all_tasks(TurnAbortReason::Replaced).await; self.clear_connector_selection().await; + self.start_task(turn_context, input, task).await; + } + async fn start_task( + self: &Arc, + turn_context: Arc, + input: Vec, + task: T, + ) { let task: Arc = Arc::new(task); let task_kind = task.kind(); let span_name = task.span_name(); @@ -224,6 +232,22 @@ impl Session { .await; } + pub(crate) async fn ensure_task_for_queued_response_items(self: &Arc) { + if !self.has_queued_response_items_for_next_turn().await { + return; + } + + if self.active_turn.lock().await.is_some() { + return; + } + + let turn_context = self.new_default_turn().await; + self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + .await; + self.start_task(turn_context, Vec::new(), RegularTask::new()) + .await; + } + pub async fn abort_all_tasks(self: &Arc, reason: TurnAbortReason) { if let Some(mut active_turn) = self.take_active_turn().await { for task in active_turn.drain_tasks() { @@ -233,6 +257,9 @@ impl Session { // in-flight approval wait can surface as a model-visible rejection before TurnAborted. active_turn.clear_pending().await; } + if reason == TurnAbortReason::Interrupted { + self.ensure_task_for_queued_response_items().await; + } } pub async fn on_task_finished( @@ -371,6 +398,9 @@ impl Session { let mut turn = ActiveTurn::default(); let mut turn_state = turn.turn_state.lock().await; turn_state.token_usage_at_turn_start = token_usage_at_turn_start; + for item in self.take_queued_response_items_for_next_turn().await { + turn_state.push_pending_input(item); + } drop(turn_state); turn.add_task(task); *active = Some(turn); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index a63cf2cb94..d93bf37187 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -3,6 +3,8 @@ use crate::CodexAuth; use crate::ModelProviderInfo; use crate::OPENAI_PROVIDER_ID; use crate::agent::AgentControl; +use crate::agent::inter_agent_instruction::InterAgentDelivery; +use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::codex::Codex; use crate::codex::CodexSpawnArgs; use crate::codex::CodexSpawnOk; @@ -26,6 +28,8 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills::SkillsManager; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; +#[cfg(test)] +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpServerRefreshConfig; @@ -606,6 +610,29 @@ impl ThreadManagerState { thread.submit(op).await } + #[cfg(test)] + /// Append a prebuilt message to a thread by ID outside the normal user-input path. + pub(crate) async fn append_message( + &self, + thread_id: ThreadId, + message: ResponseItem, + ) -> CodexResult { + let thread = self.get_thread(thread_id).await?; + thread.append_message(message).await + } + + pub(crate) async fn deliver_inter_agent_instruction( + &self, + thread_id: ThreadId, + instruction: InterAgentInstruction, + delivery: InterAgentDelivery, + ) -> CodexResult { + let thread = self.get_thread(thread_id).await?; + thread + .deliver_inter_agent_instruction(instruction, delivery) + .await + } + /// Remove a thread from the manager by ID, returning it when present. pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option> { self.threads.write().await.remove(thread_id) diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 8fc4dd5155..25f70c7305 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -1,7 +1,15 @@ use super::*; +use crate::agent::inter_agent_instruction::InterAgentDelivery; +use crate::agent::inter_agent_instruction::InterAgentInstruction; pub(crate) struct Handler; +fn can_use_v2_inter_agent_instruction(items: &[UserInput]) -> bool { + items + .iter() + .all(|item| matches!(item, UserInput::Text { .. })) +} + #[async_trait] impl ToolHandler for Handler { type Output = SendInputResult; @@ -52,12 +60,40 @@ impl ToolHandler for Handler { .into(), ) .await; - let result = session - .services - .agent_control - .send_input(receiver_thread_id, input_items) - .await - .map_err(|err| collab_agent_error(receiver_thread_id, err)); + let agent_control = session.services.agent_control.clone(); + let result = if turn.config.features.enabled(Feature::MultiAgentV2) + && can_use_v2_inter_agent_instruction(&input_items) + { + let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { + FunctionCallError::RespondToModel( + "target agent is missing an agent_path".to_string(), + ) + })?; + let instruction = InterAgentInstruction::new( + turn.session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root), + receiver_agent_path, + Vec::new(), + prompt.clone(), + ); + agent_control + .deliver_inter_agent_instruction( + receiver_thread_id, + instruction, + if args.interrupt { + InterAgentDelivery::NextTurn + } else { + InterAgentDelivery::CurrentTurn + }, + ) + .await + } else { + agent_control + .send_input(receiver_thread_id, input_items) + .await + } + .map_err(|err| collab_agent_error(receiver_thread_id, err)); let status = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index d8b2a713a5..b5ea255d89 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -7,6 +7,7 @@ use crate::codex::make_session_and_context; use crate::config::DEFAULT_AGENT_MAX_DEPTH; use crate::config::types::ShellEnvironmentPolicy; use crate::function_tool::FunctionCallError; +use crate::protocol::AgentStatus; use crate::protocol::AskForApproval; use crate::protocol::FileSystemSandboxPolicy; use crate::protocol::NetworkSandboxPolicy; @@ -14,6 +15,9 @@ use crate::protocol::Op; use crate::protocol::SandboxPolicy; use crate::protocol::SessionSource; use crate::protocol::SubAgentSource; +use crate::state::TaskKind; +use crate::tasks::SessionTask; +use crate::tasks::SessionTaskContext; use crate::tools::context::ToolOutput; use crate::turn_diff_tracker::TurnDiffTracker; use codex_features::Feature; @@ -24,6 +28,7 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::RolloutItem; +use codex_protocol::user_input::UserInput; use pretty_assertions::assert_eq; use serde::Deserialize; use serde_json::json; @@ -33,6 +38,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; use tokio::time::timeout; +use tokio_util::sync::CancellationToken; fn invocation( session: Arc, @@ -68,6 +74,31 @@ fn thread_manager() -> ThreadManager { ) } +#[derive(Clone, Copy)] +struct NeverEndingTask; + +#[async_trait::async_trait] +impl SessionTask for NeverEndingTask { + fn kind(&self) -> TaskKind { + TaskKind::Regular + } + + fn span_name(&self) -> &'static str { + "session_task.multi_agent_never_ending" + } + + async fn run( + self: Arc, + _session: Arc, + _ctx: Arc, + _input: Vec, + cancellation_token: CancellationToken, + ) -> Option { + cancellation_token.cancelled().await; + None + } +} + fn expect_text_output(output: T) -> (String, Option) where T: ToolOutput, @@ -337,6 +368,307 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( )) .await .expect("send_input should accept v2 path"); + + let child_thread = manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + timeout(Duration::from_secs(2), async { + loop { + let history_items = child_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let recorded = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "assistant" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::OutputText { text } + if text + == "author: /root\nrecipient: /root/test_process\nother_recipients: []\nContent: continue" + )) + ) + }); + let saw_user_message = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } if text == "continue" + )) + ) + }); + if recorded && !saw_user_message { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("v2 send_input should record assistant envelope"); +} + +#[tokio::test] +async fn multi_agent_v2_send_input_accepts_structured_items() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = turn.config.as_ref().clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + SpawnAgentHandler + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "boot worker", + "task_name": "worker" + })), + )) + .await + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let thread = manager + .get_thread(agent_id) + .await + .expect("worker thread should exist"); + let invocation = invocation( + session, + turn, + "send_input", + function_payload(json!({ + "target": agent_id.to_string(), + "items": [ + {"type": "mention", "name": "drive", "path": "app://google_drive"}, + {"type": "text", "text": "read the folder"} + ] + })), + ); + + SendInputHandler + .handle(invocation) + .await + .expect("structured items should be accepted in v2"); + + let expected = Op::UserInput { + items: vec![ + UserInput::Mention { + name: "drive".to_string(), + path: "app://google_drive".to_string(), + }, + UserInput::Text { + text: "read the folder".to_string(), + text_elements: Vec::new(), + }, + ], + final_output_json_schema: None, + }; + let captured = manager + .captured_ops() + .into_iter() + .find(|(id, op)| *id == agent_id && *op == expected); + assert_eq!(captured, Some((agent_id, expected))); + + timeout(Duration::from_secs(2), async { + loop { + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let recorded_assistant_envelope = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "assistant" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::OutputText { text } + if text + == "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: [mention:$drive](app://google_drive)\nread the folder" + )) + ) + }); + let saw_user_message = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } + if text == "read the folder" + || text == "[mention:$drive](app://google_drive)\nread the folder" + )) + ) + }); + if !recorded_assistant_envelope && saw_user_message { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("structured items should stay on the legacy user-input path"); + + let _ = thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); +} + +#[tokio::test] +async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = turn.config.as_ref().clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + SpawnAgentHandler + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "boot worker", + "task_name": "worker" + })), + )) + .await + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let thread = manager + .get_thread(agent_id) + .await + .expect("worker thread should exist"); + + let active_turn = thread.codex.session.new_default_turn().await; + thread + .codex + .session + .spawn_task( + Arc::clone(&active_turn), + vec![UserInput::Text { + text: "working".to_string(), + text_elements: Vec::new(), + }], + NeverEndingTask, + ) + .await; + + SendInputHandler + .handle(invocation( + session, + turn, + "send_input", + function_payload(json!({ + "target": agent_id.to_string(), + "message": "continue", + "interrupt": true + })), + )) + .await + .expect("interrupting v2 send_input should succeed"); + + let ops = manager.captured_ops(); + let ops_for_agent: Vec<&Op> = ops + .iter() + .filter_map(|(id, op)| (*id == agent_id).then_some(op)) + .collect(); + assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt))); + assert!(!ops_for_agent.iter().any(|op| matches!( + op, + Op::UserInput { items, .. } + if items.iter().any(|item| matches!( + item, + UserInput::Text { text, .. } if text == "continue" + )) + ))); + + timeout(Duration::from_secs(5), async { + loop { + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let saw_envelope = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "assistant" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::OutputText { text } + if text + == "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue" + )) + ) + }); + let saw_user_message = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } if text == "continue" + )) + ) + }); + if saw_envelope && !saw_user_message { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("interrupting v2 send_input should preserve the redirected message"); + + let _ = thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); } #[tokio::test] From f55f5c258f0588dd9acc38811a2260d5debc9bd3 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 23 Mar 2026 12:07:59 -0700 Subject: [PATCH 18/38] Fix: proactive auth refresh to reload guarded disk state first (#15357) ## Summary Fix a managed ChatGPT auth bug where a stale Codex process could proactively refresh using an old in-memory refresh token even after another process had already rotated auth on disk. This changes the proactive `AuthManager::auth()` path to reuse the existing guarded `refresh_token()` flow instead of calling the refresh endpoint directly from cached auth state. ## Original Issue Users reported repeated `codexd` log lines like: ```text ERROR codex_core::auth: Failed to refresh token: error sending request for url (https://auth.openai.com/oauth/token) ``` In practice this showed up most often when multiple `codexd` processes were left running. Killing the extra processes stopped the noise, which suggested the issue was caused by stale auth state across processes rather than invalid user credentials. ## Diagnosis The bug was in the proactive refresh path used by `AuthManager::auth()`: - Process A could refresh successfully, rotate refresh token `R0` to `R1`, and persist the updated auth state plus `last_refresh` to disk. - Process B could keep an older auth snapshot cached in memory, still holding `R0` and the old `last_refresh`. - Later, when Process B called `auth()`, it checked staleness from its cached in-memory auth instead of first reloading from disk. - Because that cached `last_refresh` was stale, Process B would proactively call `/oauth/token` with stale refresh token `R0`. - On failure, `auth()` logged the refresh error but kept returning the same stale cached auth, so repeated `auth()` calls could keep retrying with dead state. This differed from the existing unauthorized-recovery flow, which already did the safer thing: guarded reload from disk first, then refresh only if the on-disk auth was unchanged. ## What Changed - Switched proactive refresh in `AuthManager::auth()` to: - do a pure staleness check on cached auth - call `refresh_token()` when stale - return the original cached auth on genuine refresh failure, preserving existing outward behavior - Removed the direct proactive refresh-from-cached-state path - Added regression tests covering: - stale cached auth with newer same-account auth already on disk - the same scenario even when the refresh endpoint would fail if called ## Why This Fix `refresh_token()` already contains the right cross-process safety behavior: - guarded reload from disk - same-account verification - skip-refresh when another process already changed auth Reusing that path makes proactive refresh consistent with unauthorized recovery and prevents stale processes from trying to refresh already-rotated tokens. ## Testing Test shape: - create a fresh temp `CODEX_HOME` from `~/.codex/auth.json` - force `last_refresh` to an old timestamp so proactive refresh is required - start two long-lived helper processes against the same auth file - start `B` first so it caches stale auth and sleeps - start `A` second so it refreshes first - point both at a local mock `/oauth/token` server - inspect whether `B` makes a second refresh request with the stale in-memory token, or reloads the rotated token from disk ### Before the fix The repro showed the bug clearly: the mock server saw two refreshes with the same stale token, `A` rotated to a new token, and `B` still returned the stale token instead of reloading from disk. ```text POST /oauth/token refresh_token=rt_j6s0... POST /oauth/token refresh_token=rt_j6s0... B:cached_before=rt_j6s0... B:cached_after=rt_j6s0... B:returned=rt_j6s0... A:cached_before=rt_j6s0... A:cached_after=rotated-refresh-token-logged-run-v2 A:returned=rotated-refresh-token-logged-run-v2 ``` ### After the fix After the fix, the mock server saw only one refresh request. `A` refreshed once, and `B` started with the stale token but reloaded and returned the rotated token. ```text POST /oauth/token refresh_token=rt_j6s0... B:cached_before=rt_j6s0... B:cached_after=rotated-refresh-token-fix-branch B:returned=rotated-refresh-token-fix-branch A:cached_before=rt_j6s0... A:cached_after=rotated-refresh-token-fix-branch A:returned=rotated-refresh-token-fix-branch ``` This shows the new behavior: `A` refreshes once, then `B` reuses the updated auth from disk instead of making a second refresh request with the stale token. --- codex-rs/cloud-requirements/src/lib.rs | 95 +++++++++++++++---- codex-rs/core/tests/suite/auth_refresh.rs | 110 ++++++++++++++++++++++ codex-rs/login/src/auth/manager.rs | 26 ++--- 3 files changed, 196 insertions(+), 35 deletions(-) diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index e37a85dc1d..fb0f62a342 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -883,6 +883,24 @@ mod tests { account_id: Option<&str>, access_token: &str, refresh_token: &str, + ) -> serde_json::Value { + chatgpt_auth_json_with_last_refresh( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + "2025-01-01T00:00:00Z", + ) + } + + fn chatgpt_auth_json_with_last_refresh( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + last_refresh: &str, ) -> serde_json::Value { chatgpt_auth_json_with_mode( plan_type, @@ -890,6 +908,7 @@ mod tests { account_id, access_token, refresh_token, + last_refresh, None, ) } @@ -900,6 +919,7 @@ mod tests { account_id: Option<&str>, access_token: &str, refresh_token: &str, + last_refresh: &str, auth_mode: Option<&str>, ) -> serde_json::Value { let header = json!({ "alg": "none", "typ": "JWT" }); @@ -925,7 +945,7 @@ mod tests { "refresh_token": refresh_token, "account_id": account_id, }, - "last_refresh": "2025-01-01T00:00:00Z", + "last_refresh": last_refresh, }); if let Some(auth_mode) = auth_mode { auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); @@ -1262,24 +1282,43 @@ enabled = false #[tokio::test] async fn fetch_cloud_requirements_recovers_after_unauthorized_reload() { - let auth = managed_auth_context( - "business", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - ); + let auth_home = tempdir().expect("tempdir"); write_auth_json( - auth._home.path(), - chatgpt_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + // Keep auth "fresh" so the first request hits unauthorized recovery + // instead of AuthManager::auth() proactively reloading from disk. + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new(AuthManager::new( + auth_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( "business", Some("user-12345"), Some("account-12345"), "fresh-access-token", "test-refresh-token", + "3025-01-01T00:00:00Z", ), ) .expect("write refreshed auth"); + let auth = ManagedAuthContext { + _home: auth_home, + manager: auth_manager, + }; let fetcher = Arc::new(TokenFetcher { expected_token: "fresh-access-token".to_string(), @@ -1314,24 +1353,41 @@ enabled = false #[tokio::test] async fn fetch_cloud_requirements_recovers_after_unauthorized_reload_updates_cache_identity() { - let auth = managed_auth_context( - "business", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - ); + let auth_home = tempdir().expect("tempdir"); write_auth_json( - auth._home.path(), - chatgpt_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new(AuthManager::new( + auth_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( "business", Some("user-99999"), Some("account-12345"), "fresh-access-token", "test-refresh-token", + "3025-01-01T00:00:00Z", ), ) .expect("write refreshed auth"); + let auth = ManagedAuthContext { + _home: auth_home, + manager: auth_manager, + }; let fetcher = Arc::new(TokenFetcher { expected_token: "fresh-access-token".to_string(), @@ -1432,6 +1488,7 @@ enabled = false Some("account-12345"), "test-access-token", "test-refresh-token", + "2025-01-01T00:00:00Z", Some("chatgptAuthTokens"), ), ) diff --git a/codex-rs/core/tests/suite/auth_refresh.rs b/codex-rs/core/tests/suite/auth_refresh.rs index 23ed87aa98..278124c63a 100644 --- a/codex-rs/core/tests/suite/auth_refresh.rs +++ b/codex-rs/core/tests/suite/auth_refresh.rs @@ -381,6 +381,116 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> { Ok(()) } +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + + let ctx = RefreshTokenTestContext::new(&server)?; + let stale_refresh = Utc::now() - Duration::days(9); + let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens), + last_refresh: Some(stale_refresh), + }; + ctx.write_auth(&initial_auth)?; + + let fresh_refresh = Utc::now() - Duration::days(1); + let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token"); + let disk_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(disk_tokens.clone()), + last_refresh: Some(fresh_refresh), + }; + save_auth( + ctx.codex_home.path(), + &disk_auth, + AuthCredentialsStoreMode::File, + )?; + + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should reload from disk")?; + let cached = cached_auth + .get_token_data() + .context("token data should reload from disk")?; + assert_eq!(cached, disk_tokens); + + let stored = ctx.load_auth()?; + assert_eq!(stored, disk_auth); + + let requests = server.received_requests().await.unwrap_or_default(); + assert!(requests.is_empty(), "expected no refresh token requests"); + + Ok(()) +} + +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { + "code": "refresh_token_expired" + } + }))) + .expect(0) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server)?; + let stale_refresh = Utc::now() - Duration::days(9); + let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens), + last_refresh: Some(stale_refresh), + }; + ctx.write_auth(&initial_auth)?; + + let fresh_refresh = Utc::now() - Duration::days(1); + let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token"); + let disk_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(disk_tokens.clone()), + last_refresh: Some(fresh_refresh), + }; + save_auth( + ctx.codex_home.path(), + &disk_auth, + AuthCredentialsStoreMode::File, + )?; + + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should reload from disk")?; + let cached = cached_auth + .get_token_data() + .context("token data should reload from disk")?; + assert_eq!(cached, disk_tokens); + + let stored = ctx.load_auth()?; + assert_eq!(stored, disk_auth); + + server.verify().await; + Ok(()) +} + #[serial_test::serial(auth_refresh)] #[tokio::test] async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Result<()> { diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 1e4cd06d3a..31860cd585 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -1090,10 +1090,13 @@ impl AuthManager { } /// Current cached auth (clone). May be `None` if not logged in or load failed. - /// Refreshes cached ChatGPT tokens if they are stale before returning. + /// For stale managed ChatGPT auth, first performs a guarded reload and then + /// refreshes only if the on-disk auth is unchanged. pub async fn auth(&self) -> Option { let auth = self.auth_cached()?; - if let Err(err) = self.refresh_if_stale(&auth).await { + if Self::is_stale_for_proactive_refresh(&auth) + && let Err(err) = self.refresh_token().await + { tracing::error!("Failed to refresh token: {}", err); return Some(auth); } @@ -1320,30 +1323,21 @@ impl AuthManager { self.auth_cached().as_ref().map(CodexAuth::auth_mode) } - async fn refresh_if_stale(&self, auth: &CodexAuth) -> Result { + fn is_stale_for_proactive_refresh(auth: &CodexAuth) -> bool { let chatgpt_auth = match auth { CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth, - _ => return Ok(false), + _ => return false, }; let auth_dot_json = match chatgpt_auth.current_auth_json() { Some(auth_dot_json) => auth_dot_json, - None => return Ok(false), - }; - let tokens = match auth_dot_json.tokens { - Some(tokens) => tokens, - None => return Ok(false), + None => return false, }; let last_refresh = match auth_dot_json.last_refresh { Some(last_refresh) => last_refresh, - None => return Ok(false), + None => return false, }; - if last_refresh >= Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) { - return Ok(false); - } - self.refresh_and_persist_chatgpt_token(chatgpt_auth, tokens.refresh_token) - .await?; - Ok(true) + last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) } async fn refresh_external_auth( From b5d0a5518ded010f9c78227ec723e63f072dbd83 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 23 Mar 2026 12:38:39 -0700 Subject: [PATCH 19/38] Plugins TUI install/uninstall (#15342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add install/uninstall actions to the TUI plugins menu - Wire plugin install/uninstall through both TUI and `tui_app_server` - Refresh config/plugin state after changes so the UI updates immediately - Add a post-install app setup flow for plugins that require additional app auth Screenshot 2026-03-20 at 4 08 44 PM Screenshot 2026-03-20 at 4 08 54 PM Screenshot 2026-03-20 at 4 09 07 PM Screenshot 2026-03-20 at 4 09 24 PM Note/known issue: The /plugin install flow fails in `tui_app_server` because after a successful install it tries to trigger a ReloadUserConfig operation, but `tui_app_server` has not yet implemented transport for that operation, so it falls through to the generic “Not available in app-server TUI yet” stub. --- codex-rs/tui/src/app.rs | 244 ++++++++ codex-rs/tui/src/app_event.rs | 53 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 53 +- codex-rs/tui/src/chatwidget.rs | 15 + codex-rs/tui/src/chatwidget/plugins.rs | 421 +++++++++++++- ...ests__plugin_detail_popup_installable.snap | 16 + ...ts__plugins_popup_curated_marketplace.snap | 17 + ...t__tests__plugins_popup_loading_state.snap | 9 + ..._tests__plugins_popup_search_filtered.snap | 12 + codex-rs/tui/src/chatwidget/tests.rs | 521 ++++++++++++++++++ codex-rs/tui_app_server/src/app.rs | 193 +++++++ codex-rs/tui_app_server/src/app_event.rs | 53 ++ .../src/bottom_pane/chat_composer.rs | 53 +- codex-rs/tui_app_server/src/chatwidget.rs | 11 + .../tui_app_server/src/chatwidget/plugins.rs | 421 +++++++++++++- ...ests__plugin_detail_popup_installable.snap | 16 + ...ts__plugins_popup_curated_marketplace.snap | 17 + ...t__tests__plugins_popup_loading_state.snap | 9 + ..._tests__plugins_popup_search_filtered.snap | 12 + .../tui_app_server/src/chatwidget/tests.rs | 521 ++++++++++++++++++ 20 files changed, 2611 insertions(+), 56 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4086efd985..4860787e80 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -45,10 +45,14 @@ use codex_app_server_client::InProcessClientStartArgs; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; use codex_arg0::Arg0DispatchPaths; use codex_core::AuthManager; @@ -355,6 +359,72 @@ async fn request_plugin_detail( response } +async fn request_plugin_install( + arg0_paths: Arg0DispatchPaths, + config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + feedback: codex_feedback::CodexFeedback, + params: PluginInstallParams, +) -> Result { + let client = start_plugin_request_client( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + ) + .await?; + let request_handle = client.request_handle(); + let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4())); + let response = request_handle + .request_typed(ClientRequest::PluginInstall { request_id, params }) + .await + .wrap_err("plugin/install failed in legacy TUI"); + if let Err(err) = client.shutdown().await { + tracing::warn!(%err, "failed to shut down embedded app server after plugin/install"); + } + response +} + +async fn request_plugin_uninstall( + arg0_paths: Arg0DispatchPaths, + config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + feedback: codex_feedback::CodexFeedback, + plugin_id: String, +) -> Result { + let client = start_plugin_request_client( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + ) + .await?; + let request_handle = client.request_handle(); + let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4())); + let response = request_handle + .request_typed(ClientRequest::PluginUninstall { + request_id, + params: PluginUninstallParams { + plugin_id, + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/uninstall failed in legacy TUI"); + if let Err(err) = client.shutdown().await { + tracing::warn!(%err, "failed to shut down embedded app server after plugin/uninstall"); + } + response +} + fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) { let mut disabled_folders = Vec::new(); @@ -1365,6 +1435,85 @@ impl App { }); } + fn fetch_plugin_install( + &mut self, + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + ) { + let config = self.config.clone(); + let arg0_paths = self.arg0_paths.clone(); + let cli_kv_overrides = self.cli_kv_overrides.clone(); + let loader_overrides = self.loader_overrides.clone(); + let cloud_requirements = self.cloud_requirements.clone(); + let feedback = self.feedback.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let marketplace_path_for_event = marketplace_path.clone(); + let plugin_name_for_event = plugin_name.clone(); + let result = request_plugin_install( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + PluginInstallParams { + marketplace_path, + plugin_name, + force_remote_sync: false, + }, + ) + .await + .map_err(|err| format!("Failed to install plugin: {err}")); + app_event_tx.send(AppEvent::PluginInstallLoaded { + cwd: cwd_for_event, + marketplace_path: marketplace_path_for_event, + plugin_name: plugin_name_for_event, + plugin_display_name, + result, + }); + }); + } + + fn fetch_plugin_uninstall( + &mut self, + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + ) { + let config = self.config.clone(); + let arg0_paths = self.arg0_paths.clone(); + let cli_kv_overrides = self.cli_kv_overrides.clone(); + let loader_overrides = self.loader_overrides.clone(); + let cloud_requirements = self.cloud_requirements.clone(); + let feedback = self.feedback.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let plugin_id_for_event = plugin_id.clone(); + let result = request_plugin_uninstall( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + plugin_id, + ) + .await + .map_err(|err| format!("Failed to uninstall plugin: {err}")); + app_event_tx.send(AppEvent::PluginUninstallLoaded { + cwd: cwd_for_event, + plugin_id: plugin_id_for_event, + plugin_display_name, + result, + }); + }); + } + fn clear_ui_header_lines_with_version( &self, width: u16, @@ -2953,6 +3102,15 @@ impl App { AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } + AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { + if refresh_connectors { + self.chat_widget.refresh_connectors(/*force_refetch*/ true); + } + self.chat_widget.advance_plugin_install_auth_flow(); + } + AppEvent::PluginInstallAuthAbandon => { + self.chat_widget.abandon_plugin_install_auth_flow(); + } AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(cwd); } @@ -2962,6 +3120,18 @@ impl App { self.chat_widget .open_plugin_detail_loading_popup(&plugin_display_name); } + AppEvent::OpenPluginInstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_install_loading_popup(&plugin_display_name); + } + AppEvent::OpenPluginUninstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_uninstall_loading_popup(&plugin_display_name); + } AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); } @@ -2983,6 +3153,55 @@ impl App { AppEvent::PluginDetailLoaded { cwd, result } => { self.chat_widget.on_plugin_detail_loaded(cwd, result); } + AppEvent::FetchPluginInstall { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + } => { + self.fetch_plugin_install(cwd, marketplace_path, plugin_name, plugin_display_name); + } + AppEvent::FetchPluginUninstall { + cwd, + plugin_id, + plugin_display_name, + } => { + self.fetch_plugin_uninstall(cwd, plugin_id, plugin_display_name); + } + AppEvent::PluginInstallLoaded { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + result, + } => { + let install_succeeded = result.is_ok(); + if install_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!(error = %err, "failed to refresh config after plugin install"); + } + self.chat_widget.submit_op(Op::ReloadUserConfig); + } + let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( + cwd.clone(), + marketplace_path.clone(), + plugin_name.clone(), + plugin_display_name, + result, + ); + if install_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(cwd.clone()); + if should_refresh_plugin_detail { + self.fetch_plugin_detail( + cwd, + PluginReadParams { + marketplace_path, + plugin_name, + }, + ); + } + } + } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); self.refresh_status_surfaces(); @@ -3400,6 +3619,31 @@ impl App { } } } + AppEvent::PluginUninstallLoaded { + cwd, + plugin_id: _plugin_id, + plugin_display_name, + result, + } => { + let uninstall_succeeded = result.is_ok(); + if uninstall_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after plugin uninstall" + ); + } + self.chat_widget.submit_op(Op::ReloadUserConfig); + } + self.chat_widget.on_plugin_uninstall_loaded( + cwd.clone(), + plugin_display_name, + result, + ); + if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(cwd); + } + } AppEvent::PersistPersonalitySelection { personality } => { let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 71fc7be27a..b5e81edd9f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,15 +10,18 @@ use std::path::PathBuf; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallResponse; use codex_chatgpt::connectors::AppInfo; 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_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; @@ -193,6 +196,56 @@ pub(crate) enum AppEvent { result: Result, }, + /// Replace the plugins popup with an install loading state. + OpenPluginInstallLoading { + plugin_display_name: String, + }, + + /// Replace the plugins popup with an uninstall loading state. + OpenPluginUninstallLoading { + plugin_display_name: String, + }, + + /// Install a specific plugin from a marketplace. + FetchPluginInstall { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + }, + + /// Result of installing a plugin. + PluginInstallLoaded { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + result: Result, + }, + + /// Uninstall a specific plugin by canonical plugin id. + FetchPluginUninstall { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + }, + + /// Result of uninstalling a plugin. + PluginUninstallLoaded { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + result: Result, + }, + + /// Advance the post-install plugin app-auth flow. + PluginInstallAuthAdvance { + refresh_connectors: bool, + }, + + /// Abandon the post-install plugin app-auth flow. + PluginInstallAuthAbandon, + InsertHistoryCell(Box), /// Apply rollback semantics to local transcript cells. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 90106f31fd..bc3ff38599 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3584,6 +3584,24 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); + let plugin_namespaces: HashSet = + self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { + plugins + .iter() + .filter_map(|plugin| { + let (plugin_name, _) = plugin + .config_name + .split_once('@') + .unwrap_or((plugin.config_name.as_str(), "")); + let plugin_name = plugin_name.trim(); + if plugin_name.is_empty() { + None + } else { + Some(plugin_name.to_ascii_lowercase()) + } + }) + .collect() + }); let plugin_display_names: HashSet = self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { plugins @@ -3594,6 +3612,13 @@ impl ChatComposer { if let Some(skills) = self.skills.as_ref() { for skill in skills { + let is_plugin_namespaced_skill = + skill.name.split_once(':').is_some_and(|(namespace, _)| { + plugin_namespaces.contains(&namespace.to_ascii_lowercase()) + }); + if is_plugin_namespaced_skill { + continue; + } let display_name = skill_display_name(skill).to_string(); let description = skill_description(skill); let skill_name = skill.name.clone(); @@ -5403,7 +5428,7 @@ mod tests { } #[test] - fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() { + fn mention_items_hide_plugin_owned_skill_and_app_duplicates() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -5465,27 +5490,13 @@ mod tests { }], })); - let mut mention_summaries: Vec<_> = composer - .mention_items() - .into_iter() - .map(|mention| (mention.display_name, mention.category_tag, mention.path)) - .collect(); - mention_summaries.sort(); - + let mentions = composer.mention_items(); + assert_eq!(mentions.len(), 1); + assert_eq!(mentions[0].display_name, "Google Calendar".to_string()); + assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string())); assert_eq!( - mention_summaries, - vec![ - ( - "Google Calendar".to_string(), - Some("[Plugin]".to_string()), - Some("plugin://google-calendar@debug".to_string()), - ), - ( - "Google Calendar".to_string(), - Some("[Skill]".to_string()), - Some("/tmp/repo/google-calendar/SKILL.md".to_string()), - ), - ] + mentions[0].path, + Some("plugin://google-calendar@debug".to_string()) ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7a42b5c268..3f548fc23d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -57,6 +57,7 @@ use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::ConfigLayerSource; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; @@ -537,6 +538,12 @@ struct PluginListFetchState { in_flight_cwd: Option, } +#[derive(Debug, Clone)] +struct PluginInstallAuthFlowState { + plugin_display_name: String, + next_app_index: usize, +} + #[derive(Debug)] enum RateLimitErrorKind { ServerOverloaded, @@ -732,6 +739,8 @@ pub(crate) struct ChatWidget { pending_mcp_output_requests: usize, plugins_cache: PluginsCacheState, plugins_fetch_state: PluginListFetchState, + plugin_install_apps_needing_auth: Vec, + plugin_install_auth_flow: Option, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -3747,6 +3756,8 @@ impl ChatWidget { pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -3949,6 +3960,8 @@ impl ChatWidget { pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -4143,6 +4156,8 @@ impl ChatWidget { pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 5e4eaecd51..a9250672c5 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -9,12 +9,15 @@ use crate::history_cell; use crate::render::renderable::ColumnRenderable; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::PluginUninstallResponse; use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_features::Feature; +use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::style::Stylize; use ratatui::text::Line; @@ -69,19 +72,25 @@ impl ChatWidget { return; } + let auth_flow_active = self.plugin_install_auth_flow.is_some(); + match result { Ok(response) => { self.plugins_fetch_state.cache_cwd = Some(cwd); self.plugins_cache = PluginsCacheState::Ready(response.clone()); - self.refresh_plugins_popup_if_open(&response); + if !auth_flow_active { + self.refresh_plugins_popup_if_open(&response); + } } Err(err) => { - self.plugins_fetch_state.cache_cwd = None; - self.plugins_cache = PluginsCacheState::Failed(err.clone()); - let _ = self.bottom_pane.replace_selection_view_if_active( - PLUGINS_SELECTION_VIEW_ID, - self.plugins_error_popup_params(&err), - ); + if !auth_flow_active { + self.plugins_fetch_state.cache_cwd = None; + self.plugins_cache = PluginsCacheState::Failed(err.clone()); + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugins_error_popup_params(&err), + ); + } } } } @@ -130,6 +139,20 @@ impl ChatWidget { .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); } + pub(crate) fn open_plugin_install_loading_popup(&mut self, plugin_display_name: &str) { + let params = self.plugin_install_loading_popup_params(plugin_display_name); + let _ = self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); + } + + pub(crate) fn open_plugin_uninstall_loading_popup(&mut self, plugin_display_name: &str) { + let params = self.plugin_uninstall_loading_popup_params(plugin_display_name); + let _ = self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); + } + pub(crate) fn on_plugin_detail_loaded( &mut self, cwd: PathBuf, @@ -162,6 +185,291 @@ impl ChatWidget { } } + pub(crate) fn on_plugin_install_loaded( + &mut self, + cwd: PathBuf, + _marketplace_path: AbsolutePathBuf, + _plugin_name: String, + plugin_display_name: String, + result: Result, + ) -> bool { + if self.config.cwd != cwd { + return true; + } + + match result { + Ok(response) => { + self.plugin_install_apps_needing_auth = response.apps_needing_auth; + self.plugin_install_auth_flow = None; + if self.plugin_install_apps_needing_auth.is_empty() { + self.add_info_message( + format!("Installed {plugin_display_name} plugin."), + Some("No additional app authentication is required.".to_string()), + ); + true + } else { + let app_names = self + .plugin_install_apps_needing_auth + .iter() + .map(|app| app.name.as_str()) + .collect::>() + .join(", "); + self.add_info_message( + format!("Installed {plugin_display_name} plugin."), + Some(format!( + "{} app(s) still need authentication: {app_names}", + self.plugin_install_apps_needing_auth.len() + )), + ); + self.plugin_install_auth_flow = Some(super::PluginInstallAuthFlowState { + plugin_display_name, + next_app_index: 0, + }); + self.open_plugin_install_auth_popup(); + false + } + } + Err(err) => { + self.plugin_install_apps_needing_auth.clear(); + self.plugin_install_auth_flow = None; + let plugins_response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(response) => Some(response), + _ => None, + }; + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()), + ); + true + } + } + } + + pub(crate) fn on_plugin_uninstall_loaded( + &mut self, + cwd: PathBuf, + plugin_display_name: String, + result: Result, + ) { + if self.config.cwd != cwd { + return; + } + + match result { + Ok(_response) => { + self.plugin_install_apps_needing_auth.clear(); + self.plugin_install_auth_flow = None; + self.add_info_message( + format!("Uninstalled {plugin_display_name} plugin."), + Some("Bundled apps remain installed.".to_string()), + ); + } + Err(err) => { + let plugins_response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(response) => Some(response), + _ => None, + }; + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()), + ); + } + } + } + + pub(crate) fn advance_plugin_install_auth_flow(&mut self) { + let should_finish = { + let Some(flow) = self.plugin_install_auth_flow.as_mut() else { + return; + }; + flow.next_app_index += 1; + flow.next_app_index >= self.plugin_install_apps_needing_auth.len() + }; + + if should_finish { + self.finish_plugin_install_auth_flow(/*abandoned*/ false); + return; + } + + self.open_plugin_install_auth_popup(); + } + + pub(crate) fn abandon_plugin_install_auth_flow(&mut self) { + self.finish_plugin_install_auth_flow(/*abandoned*/ true); + } + + fn open_plugin_install_auth_popup(&mut self) { + let Some(params) = self.plugin_install_auth_popup_params() else { + self.finish_plugin_install_auth_flow(/*abandoned*/ false); + return; + }; + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + && let Some(params) = self.plugin_install_auth_popup_params() + { + self.bottom_pane.show_selection_view(params); + } + } + + fn plugin_install_auth_popup_params(&self) -> Option { + let flow = self.plugin_install_auth_flow.as_ref()?; + let app = self + .plugin_install_apps_needing_auth + .get(flow.next_app_index)?; + let total = self.plugin_install_apps_needing_auth.len(); + let current = flow.next_app_index + 1; + let is_installed = self.plugin_install_auth_app_is_installed(app.id.as_str()); + let status_label = if is_installed { + "Already installed in this session." + } else { + "Not installed yet." + }; + let description = app + .description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("{} plugin installed.", flow.plugin_display_name).bold(), + )); + header.push(Line::from( + format!("App setup {current}/{total}: {}", app.name).dim(), + )); + header.push(Line::from(status_label.dim())); + + let mut items = vec![SelectionItem { + name: app.name.clone(), + description, + is_disabled: true, + ..Default::default() + }]; + + if let Some(install_url) = app.install_url.clone() { + let install_label = if is_installed { + "Manage on ChatGPT" + } else { + "Install on ChatGPT" + }; + items.push(SelectionItem { + name: install_label.to_string(), + description: Some( + "Open the same ChatGPT app management link used by /apps.".to_string(), + ), + selected_description: Some("Open the app page in your browser.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenUrlInBrowser { + url: install_url.clone(), + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "ChatGPT link unavailable".to_string(), + description: Some("This app did not provide an install/manage URL.".to_string()), + is_disabled: true, + ..Default::default() + }); + } + + if is_installed { + items.push(SelectionItem { + name: "Continue".to_string(), + description: Some("This app is already installed.".to_string()), + selected_description: Some("Advance to the next app.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::PluginInstallAuthAdvance { + refresh_connectors: false, + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "I've installed it".to_string(), + description: Some( + "Trust your confirmation and continue to the next app.".to_string(), + ), + selected_description: Some( + "Continue without waiting for refresh to complete.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::PluginInstallAuthAdvance { + refresh_connectors: true, + }); + })], + ..Default::default() + }); + } + + items.push(SelectionItem { + name: "Skip remaining app setup".to_string(), + description: Some("Stop this follow-up flow for this plugin.".to_string()), + selected_description: Some("Abandon remaining required app setup.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::PluginInstallAuthAbandon); + })], + ..Default::default() + }); + + Some(SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugins_popup_hint_line()), + items, + col_width_mode: ColumnWidthMode::AutoAllRows, + ..Default::default() + }) + } + + fn plugin_install_auth_app_is_installed(&self, app_id: &str) -> bool { + self.connectors_for_mentions().is_some_and(|connectors| { + connectors + .iter() + .any(|connector| connector.id == app_id && connector.is_accessible) + }) + } + + fn finish_plugin_install_auth_flow(&mut self, abandoned: bool) { + let Some(flow) = self.plugin_install_auth_flow.take() else { + return; + }; + self.plugin_install_apps_needing_auth.clear(); + if abandoned { + self.add_info_message( + format!( + "Skipped remaining app setup for {} plugin.", + flow.plugin_display_name + ), + Some("The plugin may not be usable until required apps are installed.".to_string()), + ); + } else { + self.add_info_message( + format!( + "Completed app setup flow for {} plugin.", + flow.plugin_display_name + ), + Some("You can now continue managing plugins from /plugins.".to_string()), + ); + } + + let plugins_response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(response) => Some(response), + _ => None, + }; + if let Some(plugins_response) = plugins_response { + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugins_popup_params(&plugins_response), + ); + } + } + fn refresh_plugins_popup_if_open(&mut self, response: &PluginListResponse) { let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, @@ -212,6 +520,52 @@ impl ChatWidget { } } + fn plugin_install_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Installing {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Installing plugin...".to_string(), + description: Some("This updates when plugin installation completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + fn plugin_uninstall_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Uninstalling {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Uninstalling plugin...".to_string(), + description: Some("This updates when plugin removal completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); @@ -397,6 +751,59 @@ impl ChatWidget { ..Default::default() }]; + if plugin.summary.installed { + let uninstall_cwd = self.config.cwd.clone(); + let plugin_id = plugin.summary.id.clone(); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Uninstall plugin".to_string(), + description: Some("Remove this plugin now.".to_string()), + selected_description: Some("Remove this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginUninstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginUninstall { + cwd: uninstall_cwd.clone(), + plugin_id: plugin_id.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some( + "This plugin is not installable from this marketplace.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + let install_cwd = self.config.cwd.clone(); + let marketplace_path = plugin.marketplace_path.clone(); + let plugin_name = plugin.summary.name.clone(); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("Install this plugin now.".to_string()), + selected_description: Some("Install this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginInstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginInstall { + cwd: install_cwd.clone(), + marketplace_path: marketplace_path.clone(), + plugin_name: plugin_name.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } + items.push(SelectionItem { name: "Skills".to_string(), description: Some(plugin_skill_summary(plugin)), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap new file mode 100644 index 0000000000..d2461ec72f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Figma · ChatGPT Marketplace + Can be installed + Turn Figma files into implementation context. + +› 1. Back to plugins Return to the plugin list. + 2. Install plugin Install this plugin now. + 3. Skills design-review, extract-copy + 4. Apps Figma, Slack + 5. MCP Servers figma-mcp, docs-mcp + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap new file mode 100644 index 0000000000..54132d3bf5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Browse plugins from the ChatGPT marketplace. + Installed 1 of 3 available plugins. + Using cached marketplace data: remote sync timed out + + Type to search plugins +› Bravo Search · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. + Alpha Sync · ChatGPT Marketplace Installed · Disabled · ChatGPT Marketplace · Already + installed but disabled. + Starter · ChatGPT Marketplace Available by default · ChatGPT Marketplace · Included by + default. + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap new file mode 100644 index 0000000000..eddb869168 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Loading available plugins... + This first pass shows the ChatGPT marketplace only. + +› 1. Loading plugins... This updates when the marketplace list is ready. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap new file mode 100644 index 0000000000..b46cdb825b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Browse plugins from the ChatGPT marketplace. + Installed 0 of 3 available plugins. + + sla +› Slack · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 65daefc917..8e9ccb0404 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -18,6 +18,18 @@ use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; +use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::MarketplaceInterface; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginDetail; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::SkillSummary; use codex_core::CodexAuth; use codex_core::config::ApprovalsReviewer; use codex_core::config::Config; @@ -35,6 +47,7 @@ use codex_core::config_loader::ConfigRequirementsToml; use codex_core::config_loader::RequirementSource; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::models_manager::manager::ModelsManager; +use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core::skills::model::SkillMetadata; use codex_features::FEATURES; use codex_features::Feature; @@ -2021,6 +2034,8 @@ async fn make_chatwidget_manual( mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_partial_snapshot: None, + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, pending_mcp_output_requests: 0, @@ -7067,6 +7082,512 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::try_from( + std::env::temp_dir() + .join("codex-plugin-menu-tests") + .join(path), + ) + .expect("expected absolute test path") +} + +fn plugins_test_interface( + display_name: Option<&str>, + short_description: Option<&str>, + long_description: Option<&str>, +) -> PluginInterface { + PluginInterface { + display_name: display_name.map(str::to_string), + short_description: short_description.map(str::to_string), + long_description: long_description.map(str::to_string), + developer_name: None, + category: None, + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + } +} + +fn plugins_test_summary( + id: &str, + name: &str, + display_name: Option<&str>, + description: Option<&str>, + installed: bool, + enabled: bool, + install_policy: PluginInstallPolicy, +) -> PluginSummary { + PluginSummary { + id: id.to_string(), + name: name.to_string(), + source: PluginSource::Local { + path: plugins_test_absolute_path(&format!("plugins/{name}")), + }, + installed, + enabled, + install_policy, + auth_policy: PluginAuthPolicy::OnInstall, + interface: Some(plugins_test_interface(display_name, description, None)), + } +} + +fn plugins_test_curated_marketplace(plugins: Vec) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + path: plugins_test_absolute_path("marketplaces/chatgpt"), + interface: Some(MarketplaceInterface { + display_name: Some("ChatGPT Marketplace".to_string()), + }), + plugins, + } +} + +fn plugins_test_repo_marketplace(plugins: Vec) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: "repo".to_string(), + path: plugins_test_absolute_path("marketplaces/repo"), + interface: Some(MarketplaceInterface { + display_name: Some("Repo Marketplace".to_string()), + }), + plugins, + } +} + +fn plugins_test_response(marketplaces: Vec) -> PluginListResponse { + PluginListResponse { + marketplaces, + remote_sync_error: None, + featured_plugin_ids: Vec::new(), + } +} + +fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String { + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(response)); + chat.add_plugins_output(); + render_bottom_popup(chat, 100) +} + +fn plugins_test_detail( + summary: PluginSummary, + description: Option<&str>, + skills: &[&str], + apps: &[(&str, bool)], + mcp_servers: &[&str], +) -> PluginDetail { + PluginDetail { + marketplace_name: "ChatGPT Marketplace".to_string(), + marketplace_path: plugins_test_absolute_path("marketplaces/chatgpt"), + summary, + description: description.map(str::to_string), + skills: skills + .iter() + .map(|name| SkillSummary { + name: (*name).to_string(), + description: format!("{name} description"), + short_description: None, + interface: None, + path: PathBuf::from(format!("/skills/{name}/SKILL.md")), + }) + .collect(), + apps: apps + .iter() + .map(|(name, needs_auth)| AppSummary { + id: format!("{name}-id"), + name: (*name).to_string(), + description: Some(format!("{name} app")), + install_url: Some(format!("https://example.test/{name}")), + needs_auth: *needs_auth, + }) + .collect(), + mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(), + } +} + +fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize { + popup + .find(needle) + .unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}")) +} + +fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) { + for ch in query.chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } +} + +#[tokio::test] +async fn plugins_popup_loading_state_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + chat.add_plugins_output(); + + let popup = render_bottom_popup(&chat, 100); + assert!( + popup.contains("Loading available plugins..."), + "expected /plugins to open in a loading state before the marketplace arrives, got:\n{popup}" + ); + assert_snapshot!("plugins_popup_loading_state", popup); +} + +#[tokio::test] +async fn plugins_popup_snapshot_filters_to_curated_marketplace_and_preserves_response_order() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let mut response = plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-bravo", + "bravo", + Some("Bravo Search"), + Some("Search docs and tickets."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-alpha", + "alpha", + Some("Alpha Sync"), + Some("Already installed but disabled."), + true, + false, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-starter", + "starter", + Some("Starter"), + Some("Included by default."), + false, + true, + PluginInstallPolicy::InstalledByDefault, + ), + ]), + plugins_test_repo_marketplace(vec![plugins_test_summary( + "plugin-hidden", + "hidden", + Some("Hidden Repo Plugin"), + Some("Should not be shown in /plugins."), + false, + true, + PluginInstallPolicy::Available, + )]), + ]); + response.remote_sync_error = Some("remote sync timed out".to_string()); + + let popup = render_loaded_plugins_popup(&mut chat, response); + assert_snapshot!("plugins_popup_curated_marketplace", popup); + assert!( + !popup.contains("Hidden Repo Plugin"), + "expected /plugins to hide non-ChatGPT marketplaces, got:\n{popup}" + ); + assert!( + plugins_test_popup_row_position(&popup, "Bravo Search") + < plugins_test_popup_row_position(&popup, "Alpha Sync") + && plugins_test_popup_row_position(&popup, "Alpha Sync") + < plugins_test_popup_row_position(&popup, "Starter"), + "expected /plugins rows to keep response order, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let summary = plugins_test_summary( + "plugin-figma", + "figma", + Some("Figma"), + Some("Design handoff."), + false, + true, + PluginInstallPolicy::Available, + ); + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd, + Ok(PluginReadResponse { + plugin: plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[("Figma", true), ("Slack", false)], + &["figma-mcp", "docs-mcp"], + ), + }), + ); + + let popup = render_bottom_popup(&chat, 100); + assert_snapshot!("plugin_detail_popup_installable", popup); +} + +#[tokio::test] +async fn plugins_popup_refresh_replaces_selection_with_first_row() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-notion", + "notion", + Some("Notion"), + Some("Workspace docs."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]); + render_loaded_plugins_popup(&mut chat, initial); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + + let before = render_bottom_popup(&chat, 100); + assert!( + before.contains("› Slack"), + "expected Slack to be selected before refresh, got:\n{before}" + ); + + let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-airtable", + "airtable", + Some("Airtable"), + Some("Structured records."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-notion", + "notion", + Some("Notion"), + Some("Workspace docs."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(refreshed)); + + let after = render_bottom_popup(&chat, 100); + assert!( + after.contains("› Airtable"), + "expected refresh to rebuild the popup from the new first row, got:\n{after}" + ); + assert!( + after.contains("Slack · ChatGPT Marketplace"), + "expected refreshed popup to include the updated plugin list, got:\n{after}" + ); +} + +#[tokio::test] +async fn plugins_popup_refreshes_installed_counts_after_install() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + true, + true, + PluginInstallPolicy::Available, + ), + ])]); + let before = render_loaded_plugins_popup(&mut chat, initial); + assert!( + before.contains("Installed 1 of 2 available plugins."), + "expected initial installed count before refresh, got:\n{before}" + ); + assert!( + before.contains("Can be installed"), + "expected pre-install popup copy before refresh, got:\n{before}" + ); + + let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + true, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + true, + true, + PluginInstallPolicy::Available, + ), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(refreshed)); + + let after = render_bottom_popup(&chat, 100); + assert!( + after.contains("Installed 2 of 2 available plugins."), + "expected /plugins to refresh installed counts after install, got:\n{after}" + ); + assert!( + after.contains("Installed. Press Enter to view plugin details."), + "expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}" + ); +} + +#[tokio::test] +async fn plugins_popup_search_filters_visible_rows_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "sla"); + + let popup = render_bottom_popup(&chat, 100); + assert_snapshot!("plugins_popup_search_filtered", popup); + assert!( + !popup.contains("Calendar · ChatGPT Marketplace") + && !popup.contains("Drive · ChatGPT Marketplace"), + "expected search to leave only matching rows visible, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_search_no_matches_and_backspace_restores_results() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "zzz"); + + let no_matches = render_bottom_popup(&chat, 100); + assert!( + no_matches.contains("zzz"), + "expected popup to show the typed search query, got:\n{no_matches}" + ); + assert!( + no_matches.contains("no matches"), + "expected popup to render the no-matches UX, got:\n{no_matches}" + ); + + for _ in 0..3 { + chat.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + } + + let restored = render_bottom_popup(&chat, 100); + assert!( + restored.contains("Calendar · ChatGPT Marketplace") + && restored.contains("Slack · ChatGPT Marketplace"), + "expected clearing the query to restore the plugin rows, got:\n{restored}" + ); + assert!( + !restored.contains("no matches"), + "did not expect the no-matches state after clearing the query, got:\n{restored}" + ); +} + fn selected_permissions_popup_line(popup: &str) -> &str { popup .lines() diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index e00a9604f1..97c1e549fa 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -55,10 +55,14 @@ use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -1831,6 +1835,57 @@ impl App { }); } + fn fetch_plugin_install( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let marketplace_path_for_event = marketplace_path.clone(); + let plugin_name_for_event = plugin_name.clone(); + let result = fetch_plugin_install(request_handle, marketplace_path, plugin_name) + .await + .map_err(|err| format!("Failed to install plugin: {err}")); + app_event_tx.send(AppEvent::PluginInstallLoaded { + cwd: cwd_for_event, + marketplace_path: marketplace_path_for_event, + plugin_name: plugin_name_for_event, + plugin_display_name, + result, + }); + }); + } + + fn fetch_plugin_uninstall( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let plugin_id_for_event = plugin_id.clone(); + let result = fetch_plugin_uninstall(request_handle, plugin_id) + .await + .map_err(|err| format!("Failed to uninstall plugin: {err}")); + app_event_tx.send(AppEvent::PluginUninstallLoaded { + cwd: cwd_for_event, + plugin_id: plugin_id_for_event, + plugin_display_name, + result, + }); + }); + } + /// Process the completed MCP inventory fetch: clear the loading spinner, then /// render either the full tool/resource listing or an error into chat history. /// @@ -3555,6 +3610,15 @@ impl App { AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } + AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { + if refresh_connectors { + self.chat_widget.refresh_connectors(/*force_refetch*/ true); + } + self.chat_widget.advance_plugin_install_auth_flow(); + } + AppEvent::PluginInstallAuthAbandon => { + self.chat_widget.abandon_plugin_install_auth_flow(); + } AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(app_server, cwd); } @@ -3564,6 +3628,18 @@ impl App { self.chat_widget .open_plugin_detail_loading_popup(&plugin_display_name); } + AppEvent::OpenPluginInstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_install_loading_popup(&plugin_display_name); + } + AppEvent::OpenPluginUninstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_uninstall_loading_popup(&plugin_display_name); + } AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } @@ -3573,6 +3649,62 @@ impl App { AppEvent::PluginDetailLoaded { cwd, result } => { self.chat_widget.on_plugin_detail_loaded(cwd, result); } + AppEvent::FetchPluginInstall { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + } => { + self.fetch_plugin_install( + app_server, + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + ); + } + AppEvent::FetchPluginUninstall { + cwd, + plugin_id, + plugin_display_name, + } => { + self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name); + } + AppEvent::PluginInstallLoaded { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + result, + } => { + let install_succeeded = result.is_ok(); + if install_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!(error = %err, "failed to refresh config after plugin install"); + } + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( + cwd.clone(), + marketplace_path.clone(), + plugin_name.clone(), + plugin_display_name, + result, + ); + if install_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(app_server, cwd.clone()); + if should_refresh_plugin_detail { + self.fetch_plugin_detail( + app_server, + cwd, + PluginReadParams { + marketplace_path, + plugin_name, + }, + ); + } + } + } AppEvent::FetchMcpInventory => { self.fetch_mcp_inventory(app_server); } @@ -4012,6 +4144,31 @@ impl App { } } } + AppEvent::PluginUninstallLoaded { + cwd, + plugin_id: _plugin_id, + plugin_display_name, + result, + } => { + let uninstall_succeeded = result.is_ok(); + if uninstall_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after plugin uninstall" + ); + } + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + self.chat_widget.on_plugin_uninstall_loaded( + cwd.clone(), + plugin_display_name, + result, + ); + if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(app_server, cwd); + } + } AppEvent::PersistPersonalitySelection { personality } => { let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) @@ -5133,6 +5290,42 @@ async fn fetch_plugin_detail( .wrap_err("plugin/read failed in app-server TUI") } +async fn fetch_plugin_install( + request_handle: AppServerRequestHandle, + marketplace_path: AbsolutePathBuf, + plugin_name: String, +) -> Result { + let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PluginInstall { + request_id, + params: PluginInstallParams { + marketplace_path, + plugin_name, + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/install failed in app-server TUI") +} + +async fn fetch_plugin_uninstall( + request_handle: AppServerRequestHandle, + plugin_id: String, +) -> Result { + let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PluginUninstall { + request_id, + params: PluginUninstallParams { + plugin_id, + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/uninstall failed in app-server TUI") +} + /// Convert flat `McpServerStatus` responses into the per-server maps used by the /// in-process MCP subsystem (tools keyed as `mcp__{server}__{tool}`, plus /// per-server resource/template/auth maps). Test-only because the app-server TUI diff --git a/codex-rs/tui_app_server/src/app_event.rs b/codex-rs/tui_app_server/src/app_event.rs index a763410dd0..f5284fd610 100644 --- a/codex-rs/tui_app_server/src/app_event.rs +++ b/codex-rs/tui_app_server/src/app_event.rs @@ -11,9 +11,11 @@ use std::path::PathBuf; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallResponse; use codex_chatgpt::connectors::AppInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; @@ -21,6 +23,7 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::GetHistoryEntryResponseEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::RateLimitSnapshot; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; @@ -195,6 +198,56 @@ pub(crate) enum AppEvent { result: Result, }, + /// Replace the plugins popup with an install loading state. + OpenPluginInstallLoading { + plugin_display_name: String, + }, + + /// Replace the plugins popup with an uninstall loading state. + OpenPluginUninstallLoading { + plugin_display_name: String, + }, + + /// Install a specific plugin from a marketplace. + FetchPluginInstall { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + }, + + /// Result of installing a plugin. + PluginInstallLoaded { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + result: Result, + }, + + /// Uninstall a specific plugin by canonical plugin id. + FetchPluginUninstall { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + }, + + /// Result of uninstalling a plugin. + PluginUninstallLoaded { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + result: Result, + }, + + /// Advance the post-install plugin app-auth flow. + PluginInstallAuthAdvance { + refresh_connectors: bool, + }, + + /// Abandon the post-install plugin app-auth flow. + PluginInstallAuthAbandon, + /// Fetch MCP inventory via app-server RPCs and render it into history. FetchMcpInventory, diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index 06d8396f8c..98452410bb 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -3599,6 +3599,24 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); + let plugin_namespaces: HashSet = + self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { + plugins + .iter() + .filter_map(|plugin| { + let (plugin_name, _) = plugin + .config_name + .split_once('@') + .unwrap_or((plugin.config_name.as_str(), "")); + let plugin_name = plugin_name.trim(); + if plugin_name.is_empty() { + None + } else { + Some(plugin_name.to_ascii_lowercase()) + } + }) + .collect() + }); let plugin_display_names: HashSet = self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { plugins @@ -3609,6 +3627,13 @@ impl ChatComposer { if let Some(skills) = self.skills.as_ref() { for skill in skills { + let is_plugin_namespaced_skill = + skill.name.split_once(':').is_some_and(|(namespace, _)| { + plugin_namespaces.contains(&namespace.to_ascii_lowercase()) + }); + if is_plugin_namespaced_skill { + continue; + } let display_name = skill_display_name(skill).to_string(); let description = skill_description(skill); let skill_name = skill.name.clone(); @@ -5418,7 +5443,7 @@ mod tests { } #[test] - fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() { + fn mention_items_hide_plugin_owned_skill_and_app_duplicates() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -5480,27 +5505,13 @@ mod tests { }], })); - let mut mention_summaries: Vec<_> = composer - .mention_items() - .into_iter() - .map(|mention| (mention.display_name, mention.category_tag, mention.path)) - .collect(); - mention_summaries.sort(); - + let mentions = composer.mention_items(); + assert_eq!(mentions.len(), 1); + assert_eq!(mentions[0].display_name, "Google Calendar".to_string()); + assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string())); assert_eq!( - mention_summaries, - vec![ - ( - "Google Calendar".to_string(), - Some("[Plugin]".to_string()), - Some("plugin://google-calendar@debug".to_string()), - ), - ( - "Google Calendar".to_string(), - Some("[Skill]".to_string()), - Some("/tmp/repo/google-calendar/SKILL.md".to_string()), - ), - ] + mentions[0].path, + Some("plugin://google-calendar@debug".to_string()) ); } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index ad8b9e709e..3242046bf1 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -59,6 +59,7 @@ use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState; use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus; @@ -566,6 +567,12 @@ struct PluginListFetchState { in_flight_cwd: Option, } +#[derive(Debug, Clone)] +struct PluginInstallAuthFlowState { + plugin_display_name: String, + next_app_index: usize, +} + #[derive(Debug)] enum RateLimitErrorKind { ServerOverloaded, @@ -772,6 +779,8 @@ pub(crate) struct ChatWidget { connectors_force_refetch_pending: bool, plugins_cache: PluginsCacheState, plugins_fetch_state: PluginListFetchState, + plugin_install_apps_needing_auth: Vec, + plugin_install_auth_flow: Option, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -4309,6 +4318,8 @@ impl ChatWidget { connectors_force_refetch_pending: false, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index 5e4eaecd51..a9250672c5 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -9,12 +9,15 @@ use crate::history_cell; use crate::render::renderable::ColumnRenderable; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::PluginUninstallResponse; use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_features::Feature; +use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::style::Stylize; use ratatui::text::Line; @@ -69,19 +72,25 @@ impl ChatWidget { return; } + let auth_flow_active = self.plugin_install_auth_flow.is_some(); + match result { Ok(response) => { self.plugins_fetch_state.cache_cwd = Some(cwd); self.plugins_cache = PluginsCacheState::Ready(response.clone()); - self.refresh_plugins_popup_if_open(&response); + if !auth_flow_active { + self.refresh_plugins_popup_if_open(&response); + } } Err(err) => { - self.plugins_fetch_state.cache_cwd = None; - self.plugins_cache = PluginsCacheState::Failed(err.clone()); - let _ = self.bottom_pane.replace_selection_view_if_active( - PLUGINS_SELECTION_VIEW_ID, - self.plugins_error_popup_params(&err), - ); + if !auth_flow_active { + self.plugins_fetch_state.cache_cwd = None; + self.plugins_cache = PluginsCacheState::Failed(err.clone()); + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugins_error_popup_params(&err), + ); + } } } } @@ -130,6 +139,20 @@ impl ChatWidget { .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); } + pub(crate) fn open_plugin_install_loading_popup(&mut self, plugin_display_name: &str) { + let params = self.plugin_install_loading_popup_params(plugin_display_name); + let _ = self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); + } + + pub(crate) fn open_plugin_uninstall_loading_popup(&mut self, plugin_display_name: &str) { + let params = self.plugin_uninstall_loading_popup_params(plugin_display_name); + let _ = self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); + } + pub(crate) fn on_plugin_detail_loaded( &mut self, cwd: PathBuf, @@ -162,6 +185,291 @@ impl ChatWidget { } } + pub(crate) fn on_plugin_install_loaded( + &mut self, + cwd: PathBuf, + _marketplace_path: AbsolutePathBuf, + _plugin_name: String, + plugin_display_name: String, + result: Result, + ) -> bool { + if self.config.cwd != cwd { + return true; + } + + match result { + Ok(response) => { + self.plugin_install_apps_needing_auth = response.apps_needing_auth; + self.plugin_install_auth_flow = None; + if self.plugin_install_apps_needing_auth.is_empty() { + self.add_info_message( + format!("Installed {plugin_display_name} plugin."), + Some("No additional app authentication is required.".to_string()), + ); + true + } else { + let app_names = self + .plugin_install_apps_needing_auth + .iter() + .map(|app| app.name.as_str()) + .collect::>() + .join(", "); + self.add_info_message( + format!("Installed {plugin_display_name} plugin."), + Some(format!( + "{} app(s) still need authentication: {app_names}", + self.plugin_install_apps_needing_auth.len() + )), + ); + self.plugin_install_auth_flow = Some(super::PluginInstallAuthFlowState { + plugin_display_name, + next_app_index: 0, + }); + self.open_plugin_install_auth_popup(); + false + } + } + Err(err) => { + self.plugin_install_apps_needing_auth.clear(); + self.plugin_install_auth_flow = None; + let plugins_response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(response) => Some(response), + _ => None, + }; + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()), + ); + true + } + } + } + + pub(crate) fn on_plugin_uninstall_loaded( + &mut self, + cwd: PathBuf, + plugin_display_name: String, + result: Result, + ) { + if self.config.cwd != cwd { + return; + } + + match result { + Ok(_response) => { + self.plugin_install_apps_needing_auth.clear(); + self.plugin_install_auth_flow = None; + self.add_info_message( + format!("Uninstalled {plugin_display_name} plugin."), + Some("Bundled apps remain installed.".to_string()), + ); + } + Err(err) => { + let plugins_response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(response) => Some(response), + _ => None, + }; + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()), + ); + } + } + } + + pub(crate) fn advance_plugin_install_auth_flow(&mut self) { + let should_finish = { + let Some(flow) = self.plugin_install_auth_flow.as_mut() else { + return; + }; + flow.next_app_index += 1; + flow.next_app_index >= self.plugin_install_apps_needing_auth.len() + }; + + if should_finish { + self.finish_plugin_install_auth_flow(/*abandoned*/ false); + return; + } + + self.open_plugin_install_auth_popup(); + } + + pub(crate) fn abandon_plugin_install_auth_flow(&mut self) { + self.finish_plugin_install_auth_flow(/*abandoned*/ true); + } + + fn open_plugin_install_auth_popup(&mut self) { + let Some(params) = self.plugin_install_auth_popup_params() else { + self.finish_plugin_install_auth_flow(/*abandoned*/ false); + return; + }; + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + && let Some(params) = self.plugin_install_auth_popup_params() + { + self.bottom_pane.show_selection_view(params); + } + } + + fn plugin_install_auth_popup_params(&self) -> Option { + let flow = self.plugin_install_auth_flow.as_ref()?; + let app = self + .plugin_install_apps_needing_auth + .get(flow.next_app_index)?; + let total = self.plugin_install_apps_needing_auth.len(); + let current = flow.next_app_index + 1; + let is_installed = self.plugin_install_auth_app_is_installed(app.id.as_str()); + let status_label = if is_installed { + "Already installed in this session." + } else { + "Not installed yet." + }; + let description = app + .description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("{} plugin installed.", flow.plugin_display_name).bold(), + )); + header.push(Line::from( + format!("App setup {current}/{total}: {}", app.name).dim(), + )); + header.push(Line::from(status_label.dim())); + + let mut items = vec![SelectionItem { + name: app.name.clone(), + description, + is_disabled: true, + ..Default::default() + }]; + + if let Some(install_url) = app.install_url.clone() { + let install_label = if is_installed { + "Manage on ChatGPT" + } else { + "Install on ChatGPT" + }; + items.push(SelectionItem { + name: install_label.to_string(), + description: Some( + "Open the same ChatGPT app management link used by /apps.".to_string(), + ), + selected_description: Some("Open the app page in your browser.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenUrlInBrowser { + url: install_url.clone(), + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "ChatGPT link unavailable".to_string(), + description: Some("This app did not provide an install/manage URL.".to_string()), + is_disabled: true, + ..Default::default() + }); + } + + if is_installed { + items.push(SelectionItem { + name: "Continue".to_string(), + description: Some("This app is already installed.".to_string()), + selected_description: Some("Advance to the next app.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::PluginInstallAuthAdvance { + refresh_connectors: false, + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "I've installed it".to_string(), + description: Some( + "Trust your confirmation and continue to the next app.".to_string(), + ), + selected_description: Some( + "Continue without waiting for refresh to complete.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::PluginInstallAuthAdvance { + refresh_connectors: true, + }); + })], + ..Default::default() + }); + } + + items.push(SelectionItem { + name: "Skip remaining app setup".to_string(), + description: Some("Stop this follow-up flow for this plugin.".to_string()), + selected_description: Some("Abandon remaining required app setup.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::PluginInstallAuthAbandon); + })], + ..Default::default() + }); + + Some(SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugins_popup_hint_line()), + items, + col_width_mode: ColumnWidthMode::AutoAllRows, + ..Default::default() + }) + } + + fn plugin_install_auth_app_is_installed(&self, app_id: &str) -> bool { + self.connectors_for_mentions().is_some_and(|connectors| { + connectors + .iter() + .any(|connector| connector.id == app_id && connector.is_accessible) + }) + } + + fn finish_plugin_install_auth_flow(&mut self, abandoned: bool) { + let Some(flow) = self.plugin_install_auth_flow.take() else { + return; + }; + self.plugin_install_apps_needing_auth.clear(); + if abandoned { + self.add_info_message( + format!( + "Skipped remaining app setup for {} plugin.", + flow.plugin_display_name + ), + Some("The plugin may not be usable until required apps are installed.".to_string()), + ); + } else { + self.add_info_message( + format!( + "Completed app setup flow for {} plugin.", + flow.plugin_display_name + ), + Some("You can now continue managing plugins from /plugins.".to_string()), + ); + } + + let plugins_response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(response) => Some(response), + _ => None, + }; + if let Some(plugins_response) = plugins_response { + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugins_popup_params(&plugins_response), + ); + } + } + fn refresh_plugins_popup_if_open(&mut self, response: &PluginListResponse) { let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, @@ -212,6 +520,52 @@ impl ChatWidget { } } + fn plugin_install_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Installing {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Installing plugin...".to_string(), + description: Some("This updates when plugin installation completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + fn plugin_uninstall_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Uninstalling {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Uninstalling plugin...".to_string(), + description: Some("This updates when plugin removal completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); @@ -397,6 +751,59 @@ impl ChatWidget { ..Default::default() }]; + if plugin.summary.installed { + let uninstall_cwd = self.config.cwd.clone(); + let plugin_id = plugin.summary.id.clone(); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Uninstall plugin".to_string(), + description: Some("Remove this plugin now.".to_string()), + selected_description: Some("Remove this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginUninstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginUninstall { + cwd: uninstall_cwd.clone(), + plugin_id: plugin_id.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some( + "This plugin is not installable from this marketplace.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + let install_cwd = self.config.cwd.clone(); + let marketplace_path = plugin.marketplace_path.clone(); + let plugin_name = plugin.summary.name.clone(); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("Install this plugin now.".to_string()), + selected_description: Some("Install this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginInstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginInstall { + cwd: install_cwd.clone(), + marketplace_path: marketplace_path.clone(), + plugin_name: plugin_name.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } + items.push(SelectionItem { name: "Skills".to_string(), description: Some(plugin_skill_summary(plugin)), diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap new file mode 100644 index 0000000000..fe88135b86 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap @@ -0,0 +1,16 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Figma · ChatGPT Marketplace + Can be installed + Turn Figma files into implementation context. + +› 1. Back to plugins Return to the plugin list. + 2. Install plugin Install this plugin now. + 3. Skills design-review, extract-copy + 4. Apps Figma, Slack + 5. MCP Servers figma-mcp, docs-mcp + + Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap new file mode 100644 index 0000000000..a5f073bc2c --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -0,0 +1,17 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Browse plugins from the ChatGPT marketplace. + Installed 1 of 3 available plugins. + Using cached marketplace data: remote sync timed out + + Type to search plugins +› Bravo Search · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. + Alpha Sync · ChatGPT Marketplace Installed · Disabled · ChatGPT Marketplace · Already + installed but disabled. + Starter · ChatGPT Marketplace Available by default · ChatGPT Marketplace · Included by + default. + + Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap new file mode 100644 index 0000000000..741c813fb1 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap @@ -0,0 +1,9 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Loading available plugins... + This first pass shows the ChatGPT marketplace only. + +› 1. Loading plugins... This updates when the marketplace list is ready. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap new file mode 100644 index 0000000000..b12a33e638 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap @@ -0,0 +1,12 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Browse plugins from the ChatGPT marketplace. + Installed 0 of 3 available plugins. + + sla +› Slack · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. + + Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index e8173a43ff..2da8ff77a8 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -19,6 +19,7 @@ use crate::model_catalog::ModelCatalog; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState; use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus; use codex_app_server_protocol::CollabAgentTool as AppServerCollabAgentTool; @@ -32,9 +33,20 @@ use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification; use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotification; use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::MarketplaceInterface; use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginDetail; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::SkillSummary; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadItem as AppServerThreadItem; use codex_app_server_protocol::Turn as AppServerTurn; @@ -58,6 +70,7 @@ use codex_core::config_loader::ConfigRequirements; use codex_core::config_loader::ConfigRequirementsToml; use codex_core::config_loader::RequirementSource; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; +use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core::skills::model::SkillMetadata; use codex_features::FEATURES; use codex_features::Feature; @@ -2042,6 +2055,8 @@ async fn make_chatwidget_manual( mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_partial_snapshot: None, + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, plugins_cache: PluginsCacheState::default(), @@ -7664,6 +7679,512 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::try_from( + std::env::temp_dir() + .join("codex-plugin-menu-tests") + .join(path), + ) + .expect("expected absolute test path") +} + +fn plugins_test_interface( + display_name: Option<&str>, + short_description: Option<&str>, + long_description: Option<&str>, +) -> PluginInterface { + PluginInterface { + display_name: display_name.map(str::to_string), + short_description: short_description.map(str::to_string), + long_description: long_description.map(str::to_string), + developer_name: None, + category: None, + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + } +} + +fn plugins_test_summary( + id: &str, + name: &str, + display_name: Option<&str>, + description: Option<&str>, + installed: bool, + enabled: bool, + install_policy: PluginInstallPolicy, +) -> PluginSummary { + PluginSummary { + id: id.to_string(), + name: name.to_string(), + source: PluginSource::Local { + path: plugins_test_absolute_path(&format!("plugins/{name}")), + }, + installed, + enabled, + install_policy, + auth_policy: PluginAuthPolicy::OnInstall, + interface: Some(plugins_test_interface(display_name, description, None)), + } +} + +fn plugins_test_curated_marketplace(plugins: Vec) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + path: plugins_test_absolute_path("marketplaces/chatgpt"), + interface: Some(MarketplaceInterface { + display_name: Some("ChatGPT Marketplace".to_string()), + }), + plugins, + } +} + +fn plugins_test_repo_marketplace(plugins: Vec) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: "repo".to_string(), + path: plugins_test_absolute_path("marketplaces/repo"), + interface: Some(MarketplaceInterface { + display_name: Some("Repo Marketplace".to_string()), + }), + plugins, + } +} + +fn plugins_test_response(marketplaces: Vec) -> PluginListResponse { + PluginListResponse { + marketplaces, + remote_sync_error: None, + featured_plugin_ids: Vec::new(), + } +} + +fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String { + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(response)); + chat.add_plugins_output(); + render_bottom_popup(chat, 100) +} + +fn plugins_test_detail( + summary: PluginSummary, + description: Option<&str>, + skills: &[&str], + apps: &[(&str, bool)], + mcp_servers: &[&str], +) -> PluginDetail { + PluginDetail { + marketplace_name: "ChatGPT Marketplace".to_string(), + marketplace_path: plugins_test_absolute_path("marketplaces/chatgpt"), + summary, + description: description.map(str::to_string), + skills: skills + .iter() + .map(|name| SkillSummary { + name: (*name).to_string(), + description: format!("{name} description"), + short_description: None, + interface: None, + path: PathBuf::from(format!("/skills/{name}/SKILL.md")), + }) + .collect(), + apps: apps + .iter() + .map(|(name, needs_auth)| AppSummary { + id: format!("{name}-id"), + name: (*name).to_string(), + description: Some(format!("{name} app")), + install_url: Some(format!("https://example.test/{name}")), + needs_auth: *needs_auth, + }) + .collect(), + mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(), + } +} + +fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize { + popup + .find(needle) + .unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}")) +} + +fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) { + for ch in query.chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } +} + +#[tokio::test] +async fn plugins_popup_loading_state_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + chat.add_plugins_output(); + + let popup = render_bottom_popup(&chat, 100); + assert!( + popup.contains("Loading available plugins..."), + "expected /plugins to open in a loading state before the marketplace arrives, got:\n{popup}" + ); + assert_snapshot!("plugins_popup_loading_state", popup); +} + +#[tokio::test] +async fn plugins_popup_snapshot_filters_to_curated_marketplace_and_preserves_response_order() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let mut response = plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-bravo", + "bravo", + Some("Bravo Search"), + Some("Search docs and tickets."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-alpha", + "alpha", + Some("Alpha Sync"), + Some("Already installed but disabled."), + true, + false, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-starter", + "starter", + Some("Starter"), + Some("Included by default."), + false, + true, + PluginInstallPolicy::InstalledByDefault, + ), + ]), + plugins_test_repo_marketplace(vec![plugins_test_summary( + "plugin-hidden", + "hidden", + Some("Hidden Repo Plugin"), + Some("Should not be shown in /plugins."), + false, + true, + PluginInstallPolicy::Available, + )]), + ]); + response.remote_sync_error = Some("remote sync timed out".to_string()); + + let popup = render_loaded_plugins_popup(&mut chat, response); + assert_snapshot!("plugins_popup_curated_marketplace", popup); + assert!( + !popup.contains("Hidden Repo Plugin"), + "expected /plugins to hide non-ChatGPT marketplaces, got:\n{popup}" + ); + assert!( + plugins_test_popup_row_position(&popup, "Bravo Search") + < plugins_test_popup_row_position(&popup, "Alpha Sync") + && plugins_test_popup_row_position(&popup, "Alpha Sync") + < plugins_test_popup_row_position(&popup, "Starter"), + "expected /plugins rows to keep response order, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let summary = plugins_test_summary( + "plugin-figma", + "figma", + Some("Figma"), + Some("Design handoff."), + false, + true, + PluginInstallPolicy::Available, + ); + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd, + Ok(PluginReadResponse { + plugin: plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[("Figma", true), ("Slack", false)], + &["figma-mcp", "docs-mcp"], + ), + }), + ); + + let popup = render_bottom_popup(&chat, 100); + assert_snapshot!("plugin_detail_popup_installable", popup); +} + +#[tokio::test] +async fn plugins_popup_refresh_replaces_selection_with_first_row() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-notion", + "notion", + Some("Notion"), + Some("Workspace docs."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]); + render_loaded_plugins_popup(&mut chat, initial); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + + let before = render_bottom_popup(&chat, 100); + assert!( + before.contains("› Slack"), + "expected Slack to be selected before refresh, got:\n{before}" + ); + + let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-airtable", + "airtable", + Some("Airtable"), + Some("Structured records."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-notion", + "notion", + Some("Notion"), + Some("Workspace docs."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(refreshed)); + + let after = render_bottom_popup(&chat, 100); + assert!( + after.contains("› Airtable"), + "expected refresh to rebuild the popup from the new first row, got:\n{after}" + ); + assert!( + after.contains("Slack · ChatGPT Marketplace"), + "expected refreshed popup to include the updated plugin list, got:\n{after}" + ); +} + +#[tokio::test] +async fn plugins_popup_refreshes_installed_counts_after_install() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + true, + true, + PluginInstallPolicy::Available, + ), + ])]); + let before = render_loaded_plugins_popup(&mut chat, initial); + assert!( + before.contains("Installed 1 of 2 available plugins."), + "expected initial installed count before refresh, got:\n{before}" + ); + assert!( + before.contains("Can be installed"), + "expected pre-install popup copy before refresh, got:\n{before}" + ); + + let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + true, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + true, + true, + PluginInstallPolicy::Available, + ), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(refreshed)); + + let after = render_bottom_popup(&chat, 100); + assert!( + after.contains("Installed 2 of 2 available plugins."), + "expected /plugins to refresh installed counts after install, got:\n{after}" + ); + assert!( + after.contains("Installed. Press Enter to view plugin details."), + "expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}" + ); +} + +#[tokio::test] +async fn plugins_popup_search_filters_visible_rows_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "sla"); + + let popup = render_bottom_popup(&chat, 100); + assert_snapshot!("plugins_popup_search_filtered", popup); + assert!( + !popup.contains("Calendar · ChatGPT Marketplace") + && !popup.contains("Drive · ChatGPT Marketplace"), + "expected search to leave only matching rows visible, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_search_no_matches_and_backspace_restores_results() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "zzz"); + + let no_matches = render_bottom_popup(&chat, 100); + assert!( + no_matches.contains("zzz"), + "expected popup to show the typed search query, got:\n{no_matches}" + ); + assert!( + no_matches.contains("no matches"), + "expected popup to render the no-matches UX, got:\n{no_matches}" + ); + + for _ in 0..3 { + chat.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + } + + let restored = render_bottom_popup(&chat, 100); + assert!( + restored.contains("Calendar · ChatGPT Marketplace") + && restored.contains("Slack · ChatGPT Marketplace"), + "expected clearing the query to restore the plugin rows, got:\n{restored}" + ); + assert!( + !restored.contains("no matches"), + "did not expect the no-matches state after clearing the query, got:\n{restored}" + ); +} + #[tokio::test] async fn apps_popup_stays_loading_until_final_snapshot_updates() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; From 450dc289c3305bf9d94d862d6d30c4916aa2497a Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 23 Mar 2026 19:41:53 +0000 Subject: [PATCH 20/38] chore: split sub-agent v2 implementation (#15540) Just to make things cleaner --- codex-rs/core/src/tools/handlers/mod.rs | 2 + .../core/src/tools/handlers/multi_agents.rs | 370 +---------------- .../handlers/multi_agents/resume_agent.rs | 1 + .../tools/handlers/multi_agents/send_input.rs | 45 +-- .../src/tools/handlers/multi_agents/wait.rs | 2 + .../src/tools/handlers/multi_agents_common.rs | 381 ++++++++++++++++++ .../src/tools/handlers/multi_agents_tests.rs | 34 +- .../src/tools/handlers/multi_agents_v2.rs | 39 ++ .../handlers/multi_agents_v2/send_input.rs | 154 +++++++ .../tools/handlers/multi_agents_v2/spawn.rs | 204 ++++++++++ .../tools/handlers/multi_agents_v2/wait.rs | 236 +++++++++++ codex-rs/core/src/tools/spec.rs | 21 +- 12 files changed, 1058 insertions(+), 431 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/multi_agents_common.rs create mode 100644 codex-rs/core/src/tools/handlers/multi_agents_v2.rs create mode 100644 codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs create mode 100644 codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs create mode 100644 codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 7ae1d1428d..f301c68715 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -8,6 +8,8 @@ mod list_dir; mod mcp; mod mcp_resource; pub(crate) mod multi_agents; +pub(crate) mod multi_agents_common; +pub(crate) mod multi_agents_v2; mod plan; mod read_file; mod request_permissions; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 897af0d5f0..0bf7b7bcd3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -11,45 +11,33 @@ use crate::agent::agent_resolver::resolve_agent_targets; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::codex::Session; use crate::codex::TurnContext; -use crate::config::Config; -use crate::error::CodexErr; use crate::function_tool::FunctionCallError; -use crate::models_manager::manager::RefreshStrategy; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +pub(crate) use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; -use codex_features::Feature; -use codex_protocol::AgentPath; use codex_protocol::ThreadId; -use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentRef; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::CollabCloseBeginEvent; 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 codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; -use std::collections::HashMap; -use std::sync::Arc; pub(crate) use close_agent::Handler as CloseAgentHandler; pub(crate) use resume_agent::Handler as ResumeAgentHandler; @@ -57,368 +45,12 @@ pub(crate) use send_input::Handler as SendInputHandler; pub(crate) use spawn::Handler as SpawnAgentHandler; pub(crate) use wait::Handler as WaitAgentHandler; -/// Minimum wait timeout to prevent tight polling loops from burning CPU. -pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; -pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; -pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; - -fn function_arguments(payload: ToolPayload) -> Result { - match payload { - ToolPayload::Function { arguments } => Ok(arguments), - _ => Err(FunctionCallError::RespondToModel( - "collab handler received unsupported payload".to_string(), - )), - } -} - -fn tool_output_json_text(value: &T, tool_name: &str) -> String -where - T: Serialize, -{ - serde_json::to_string(value).unwrap_or_else(|err| { - JsonValue::String(format!("failed to serialize {tool_name} result: {err}")).to_string() - }) -} - -fn tool_output_response_item( - call_id: &str, - payload: &ToolPayload, - value: &T, - success: Option, - tool_name: &str, -) -> ResponseInputItem -where - T: Serialize, -{ - FunctionToolOutput::from_text(tool_output_json_text(value, tool_name), success) - .to_response_item(call_id, payload) -} - -fn tool_output_code_mode_result(value: &T, tool_name: &str) -> JsonValue -where - T: Serialize, -{ - serde_json::to_value(value).unwrap_or_else(|err| { - JsonValue::String(format!("failed to serialize {tool_name} result: {err}")) - }) -} - pub mod close_agent; mod resume_agent; mod send_input; mod spawn; pub(crate) mod wait; -fn build_wait_agent_statuses( - statuses: &HashMap, - receiver_agents: &[CollabAgentRef], -) -> Vec { - if statuses.is_empty() { - return Vec::new(); - } - - let mut entries = Vec::with_capacity(statuses.len()); - let mut seen = HashMap::with_capacity(receiver_agents.len()); - for receiver_agent in receiver_agents { - seen.insert(receiver_agent.thread_id, ()); - if let Some(status) = statuses.get(&receiver_agent.thread_id) { - entries.push(CollabAgentStatusEntry { - thread_id: receiver_agent.thread_id, - agent_nickname: receiver_agent.agent_nickname.clone(), - agent_role: receiver_agent.agent_role.clone(), - status: status.clone(), - }); - } - } - - let mut extras = statuses - .iter() - .filter(|(thread_id, _)| !seen.contains_key(thread_id)) - .map(|(thread_id, status)| CollabAgentStatusEntry { - thread_id: *thread_id, - agent_nickname: None, - agent_role: None, - status: status.clone(), - }) - .collect::>(); - extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); - entries.extend(extras); - entries -} - -fn collab_spawn_error(err: CodexErr) -> FunctionCallError { - match err { - CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => { - FunctionCallError::RespondToModel("collab manager unavailable".to_string()) - } - CodexErr::UnsupportedOperation(message) => FunctionCallError::RespondToModel(message), - err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")), - } -} - -fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { - match err { - CodexErr::ThreadNotFound(id) => { - FunctionCallError::RespondToModel(format!("agent with id {id} not found")) - } - CodexErr::InternalAgentDied => { - FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed")) - } - CodexErr::UnsupportedOperation(_) => { - FunctionCallError::RespondToModel("collab manager unavailable".to_string()) - } - err => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")), - } -} - -fn thread_spawn_source( - parent_thread_id: ThreadId, - parent_session_source: &SessionSource, - depth: i32, - agent_role: Option<&str>, - task_name: Option, -) -> Result { - let agent_path = task_name - .as_deref() - .map(|task_name| { - parent_session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root) - .join(task_name) - .map_err(FunctionCallError::RespondToModel) - }) - .transpose()?; - Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth, - agent_path, - agent_nickname: None, - agent_role: agent_role.map(str::to_string), - })) -} - -fn parse_collab_input( - message: Option, - items: Option>, -) -> Result, FunctionCallError> { - match (message, items) { - (Some(_), Some(_)) => Err(FunctionCallError::RespondToModel( - "Provide either message or items, but not both".to_string(), - )), - (None, None) => Err(FunctionCallError::RespondToModel( - "Provide one of: message or items".to_string(), - )), - (Some(message), None) => { - if message.trim().is_empty() { - return Err(FunctionCallError::RespondToModel( - "Empty message can't be sent to an agent".to_string(), - )); - } - Ok(vec![UserInput::Text { - text: message, - text_elements: Vec::new(), - }]) - } - (None, Some(items)) => { - if items.is_empty() { - return Err(FunctionCallError::RespondToModel( - "Items can't be empty".to_string(), - )); - } - Ok(items) - } - } -} - -fn input_preview(items: &[UserInput]) -> String { - let parts: Vec = items - .iter() - .map(|item| match item { - UserInput::Text { text, .. } => text.clone(), - UserInput::Image { .. } => "[image]".to_string(), - UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), - UserInput::Skill { name, path } => { - format!("[skill:${name}]({})", path.display()) - } - UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), - _ => "[input]".to_string(), - }) - .collect(); - - parts.join("\n") -} - -/// Builds the base config snapshot for a newly spawned sub-agent. -/// -/// The returned config starts from the parent's effective config and then refreshes the -/// runtime-owned fields carried on `turn`, including model selection, reasoning settings, -/// approval policy, sandbox, and cwd. Role-specific overrides are layered after this step; -/// skipping this helper and cloning stale config state directly can send the child agent out with -/// the wrong provider or runtime policy. -pub(crate) fn build_agent_spawn_config( - base_instructions: &BaseInstructions, - turn: &TurnContext, -) -> Result { - let mut config = build_agent_shared_config(turn)?; - config.base_instructions = Some(base_instructions.text.clone()); - Ok(config) -} - -fn build_agent_resume_config( - turn: &TurnContext, - child_depth: i32, -) -> Result { - let mut config = build_agent_shared_config(turn)?; - apply_spawn_agent_overrides(&mut config, child_depth); - // For resume, keep base instructions sourced from rollout/session metadata. - config.base_instructions = None; - Ok(config) -} - -fn build_agent_shared_config(turn: &TurnContext) -> Result { - let base_config = turn.config.clone(); - let mut config = (*base_config).clone(); - config.model = Some(turn.model_info.slug.clone()); - config.model_provider = turn.provider.clone(); - config.model_reasoning_effort = turn.reasoning_effort; - config.model_reasoning_summary = Some(turn.reasoning_summary); - config.developer_instructions = turn.developer_instructions.clone(); - config.compact_prompt = turn.compact_prompt.clone(); - apply_spawn_agent_runtime_overrides(&mut config, turn)?; - - Ok(config) -} - -/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`. -/// -/// These values are chosen by the live turn rather than persisted config, so leaving them stale -/// can make a child agent disagree with its parent about approval policy, cwd, or sandboxing. -fn apply_spawn_agent_runtime_overrides( - config: &mut Config, - turn: &TurnContext, -) -> Result<(), FunctionCallError> { - config - .permissions - .approval_policy - .set(turn.approval_policy.value()) - .map_err(|err| { - FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}")) - })?; - config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); - config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - config.cwd = turn.cwd.clone(); - config - .permissions - .sandbox_policy - .set(turn.sandbox_policy.get().clone()) - .map_err(|err| { - FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) - })?; - config.permissions.file_system_sandbox_policy = turn.file_system_sandbox_policy.clone(); - config.permissions.network_sandbox_policy = turn.network_sandbox_policy; - Ok(()) -} - -fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { - if child_depth >= config.agent_max_depth { - let _ = config.features.disable(Feature::SpawnCsv); - let _ = config.features.disable(Feature::Collab); - } -} - -async fn apply_requested_spawn_agent_model_overrides( - session: &Session, - turn: &TurnContext, - config: &mut Config, - requested_model: Option<&str>, - requested_reasoning_effort: Option, -) -> Result<(), FunctionCallError> { - if requested_model.is_none() && requested_reasoning_effort.is_none() { - return Ok(()); - } - - if let Some(requested_model) = requested_model { - let available_models = session - .services - .models_manager - .list_models(RefreshStrategy::Offline) - .await; - let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?; - let selected_model_info = session - .services - .models_manager - .get_model_info(&selected_model_name, config) - .await; - - config.model = Some(selected_model_name.clone()); - if let Some(reasoning_effort) = requested_reasoning_effort { - validate_spawn_agent_reasoning_effort( - &selected_model_name, - &selected_model_info.supported_reasoning_levels, - reasoning_effort, - )?; - config.model_reasoning_effort = Some(reasoning_effort); - } else { - config.model_reasoning_effort = selected_model_info.default_reasoning_level; - } - - return Ok(()); - } - - if let Some(reasoning_effort) = requested_reasoning_effort { - validate_spawn_agent_reasoning_effort( - &turn.model_info.slug, - &turn.model_info.supported_reasoning_levels, - reasoning_effort, - )?; - config.model_reasoning_effort = Some(reasoning_effort); - } - - Ok(()) -} - -fn find_spawn_agent_model_name( - available_models: &[codex_protocol::openai_models::ModelPreset], - requested_model: &str, -) -> Result { - available_models - .iter() - .find(|model| model.model == requested_model) - .map(|model| model.model.clone()) - .ok_or_else(|| { - let available = available_models - .iter() - .map(|model| model.model.as_str()) - .collect::>() - .join(", "); - FunctionCallError::RespondToModel(format!( - "Unknown model `{requested_model}` for spawn_agent. Available models: {available}" - )) - }) -} - -fn validate_spawn_agent_reasoning_effort( - model: &str, - supported_reasoning_levels: &[ReasoningEffortPreset], - requested_reasoning_effort: ReasoningEffort, -) -> Result<(), FunctionCallError> { - if supported_reasoning_levels - .iter() - .any(|preset| preset.effort == requested_reasoning_effort) - { - return Ok(()); - } - - let supported = supported_reasoning_levels - .iter() - .map(|preset| preset.effort.to_string()) - .collect::>() - .join(", "); - Err(FunctionCallError::RespondToModel(format!( - "Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}" - ))) -} - #[cfg(test)] #[path = "multi_agents_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index 85e879c1bb..09526182f2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -1,5 +1,6 @@ use super::*; use crate::agent::next_thread_spawn_depth; +use std::sync::Arc; pub(crate) struct Handler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 25f70c7305..f649482a2b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -1,15 +1,7 @@ use super::*; -use crate::agent::inter_agent_instruction::InterAgentDelivery; -use crate::agent::inter_agent_instruction::InterAgentInstruction; pub(crate) struct Handler; -fn can_use_v2_inter_agent_instruction(items: &[UserInput]) -> bool { - items - .iter() - .all(|item| matches!(item, UserInput::Text { .. })) -} - #[async_trait] impl ToolHandler for Handler { type Output = SendInputResult; @@ -61,39 +53,10 @@ impl ToolHandler for Handler { ) .await; let agent_control = session.services.agent_control.clone(); - let result = if turn.config.features.enabled(Feature::MultiAgentV2) - && can_use_v2_inter_agent_instruction(&input_items) - { - let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { - FunctionCallError::RespondToModel( - "target agent is missing an agent_path".to_string(), - ) - })?; - let instruction = InterAgentInstruction::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - receiver_agent_path, - Vec::new(), - prompt.clone(), - ); - agent_control - .deliver_inter_agent_instruction( - receiver_thread_id, - instruction, - if args.interrupt { - InterAgentDelivery::NextTurn - } else { - InterAgentDelivery::CurrentTurn - }, - ) - .await - } else { - agent_control - .send_input(receiver_thread_id, input_items) - .await - } - .map_err(|err| collab_agent_error(receiver_thread_id, err)); + let result = agent_control + .send_input(receiver_thread_id, input_items) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err)); let status = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index 8458402ce5..d203e6b398 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -1,9 +1,11 @@ use super::*; use crate::agent::status::is_final; +use crate::error::CodexErr; use futures::FutureExt; use futures::StreamExt; use futures::stream::FuturesUnordered; use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; use tokio::sync::watch::Receiver; use tokio::time::Instant; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs new file mode 100644 index 0000000000..106f34253d --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -0,0 +1,381 @@ +use crate::agent::AgentStatus; +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::config::Config; +use crate::error::CodexErr; +use crate::function_tool::FunctionCallError; +use crate::models_manager::manager::RefreshStrategy; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use codex_features::Feature; +use codex_protocol::AgentPath; +use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::protocol::CollabAgentRef; +use codex_protocol::protocol::CollabAgentStatusEntry; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::user_input::UserInput; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::HashMap; + +/// Minimum wait timeout to prevent tight polling loops from burning CPU. +pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; +pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; +pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; + +pub(crate) fn function_arguments(payload: ToolPayload) -> Result { + match payload { + ToolPayload::Function { arguments } => Ok(arguments), + _ => Err(FunctionCallError::RespondToModel( + "collab handler received unsupported payload".to_string(), + )), + } +} + +pub(crate) fn tool_output_json_text(value: &T, tool_name: &str) -> String +where + T: Serialize, +{ + serde_json::to_string(value).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize {tool_name} result: {err}")).to_string() + }) +} + +pub(crate) fn tool_output_response_item( + call_id: &str, + payload: &ToolPayload, + value: &T, + success: Option, + tool_name: &str, +) -> ResponseInputItem +where + T: Serialize, +{ + FunctionToolOutput::from_text(tool_output_json_text(value, tool_name), success) + .to_response_item(call_id, payload) +} + +pub(crate) fn tool_output_code_mode_result(value: &T, tool_name: &str) -> JsonValue +where + T: Serialize, +{ + serde_json::to_value(value).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize {tool_name} result: {err}")) + }) +} + +pub(crate) fn build_wait_agent_statuses( + statuses: &HashMap, + receiver_agents: &[CollabAgentRef], +) -> Vec { + if statuses.is_empty() { + return Vec::new(); + } + + let mut entries = Vec::with_capacity(statuses.len()); + let mut seen = HashMap::with_capacity(receiver_agents.len()); + for receiver_agent in receiver_agents { + seen.insert(receiver_agent.thread_id, ()); + if let Some(status) = statuses.get(&receiver_agent.thread_id) { + entries.push(CollabAgentStatusEntry { + thread_id: receiver_agent.thread_id, + agent_nickname: receiver_agent.agent_nickname.clone(), + agent_role: receiver_agent.agent_role.clone(), + status: status.clone(), + }); + } + } + + let mut extras = statuses + .iter() + .filter(|(thread_id, _)| !seen.contains_key(thread_id)) + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.extend(extras); + entries +} + +pub(crate) fn collab_spawn_error(err: CodexErr) -> FunctionCallError { + match err { + CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => { + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + } + CodexErr::UnsupportedOperation(message) => FunctionCallError::RespondToModel(message), + err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")), + } +} + +pub(crate) fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { + match err { + CodexErr::ThreadNotFound(id) => { + FunctionCallError::RespondToModel(format!("agent with id {id} not found")) + } + CodexErr::InternalAgentDied => { + FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed")) + } + CodexErr::UnsupportedOperation(_) => { + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + } + err => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")), + } +} + +pub(crate) fn thread_spawn_source( + parent_thread_id: ThreadId, + parent_session_source: &SessionSource, + depth: i32, + agent_role: Option<&str>, + task_name: Option, +) -> Result { + let agent_path = task_name + .as_deref() + .map(|task_name| { + parent_session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root) + .join(task_name) + .map_err(FunctionCallError::RespondToModel) + }) + .transpose()?; + Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_nickname: None, + agent_role: agent_role.map(str::to_string), + })) +} + +pub(crate) fn parse_collab_input( + message: Option, + items: Option>, +) -> Result, FunctionCallError> { + match (message, items) { + (Some(_), Some(_)) => Err(FunctionCallError::RespondToModel( + "Provide either message or items, but not both".to_string(), + )), + (None, None) => Err(FunctionCallError::RespondToModel( + "Provide one of: message or items".to_string(), + )), + (Some(message), None) => { + if message.trim().is_empty() { + return Err(FunctionCallError::RespondToModel( + "Empty message can't be sent to an agent".to_string(), + )); + } + Ok(vec![UserInput::Text { + text: message, + text_elements: Vec::new(), + }]) + } + (None, Some(items)) => { + if items.is_empty() { + return Err(FunctionCallError::RespondToModel( + "Items can't be empty".to_string(), + )); + } + Ok(items) + } + } +} + +pub(crate) fn input_preview(items: &[UserInput]) -> String { + let parts: Vec = items + .iter() + .map(|item| match item { + UserInput::Text { text, .. } => text.clone(), + UserInput::Image { .. } => "[image]".to_string(), + UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), + UserInput::Skill { name, path } => { + format!("[skill:${name}]({})", path.display()) + } + UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), + _ => "[input]".to_string(), + }) + .collect(); + + parts.join("\n") +} + +/// Builds the base config snapshot for a newly spawned sub-agent. +/// +/// The returned config starts from the parent's effective config and then refreshes the +/// runtime-owned fields carried on `turn`, including model selection, reasoning settings, +/// approval policy, sandbox, and cwd. Role-specific overrides are layered after this step; +/// skipping this helper and cloning stale config state directly can send the child agent out with +/// the wrong provider or runtime policy. +pub(crate) fn build_agent_spawn_config( + base_instructions: &BaseInstructions, + turn: &TurnContext, +) -> Result { + let mut config = build_agent_shared_config(turn)?; + config.base_instructions = Some(base_instructions.text.clone()); + Ok(config) +} + +pub(crate) fn build_agent_resume_config( + turn: &TurnContext, + child_depth: i32, +) -> Result { + let mut config = build_agent_shared_config(turn)?; + apply_spawn_agent_overrides(&mut config, child_depth); + // For resume, keep base instructions sourced from rollout/session metadata. + config.base_instructions = None; + Ok(config) +} + +fn build_agent_shared_config(turn: &TurnContext) -> Result { + let base_config = turn.config.clone(); + let mut config = (*base_config).clone(); + config.model = Some(turn.model_info.slug.clone()); + config.model_provider = turn.provider.clone(); + config.model_reasoning_effort = turn.reasoning_effort; + config.model_reasoning_summary = Some(turn.reasoning_summary); + config.developer_instructions = turn.developer_instructions.clone(); + config.compact_prompt = turn.compact_prompt.clone(); + apply_spawn_agent_runtime_overrides(&mut config, turn)?; + + Ok(config) +} + +/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`. +/// +/// These values are chosen by the live turn rather than persisted config, so leaving them stale +/// can make a child agent disagree with its parent about approval policy, cwd, or sandboxing. +pub(crate) fn apply_spawn_agent_runtime_overrides( + config: &mut Config, + turn: &TurnContext, +) -> Result<(), FunctionCallError> { + config + .permissions + .approval_policy + .set(turn.approval_policy.value()) + .map_err(|err| { + FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}")) + })?; + config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); + config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); + config.cwd = turn.cwd.clone(); + config + .permissions + .sandbox_policy + .set(turn.sandbox_policy.get().clone()) + .map_err(|err| { + FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) + })?; + config.permissions.file_system_sandbox_policy = turn.file_system_sandbox_policy.clone(); + config.permissions.network_sandbox_policy = turn.network_sandbox_policy; + Ok(()) +} + +pub(crate) fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { + if child_depth >= config.agent_max_depth { + let _ = config.features.disable(Feature::SpawnCsv); + let _ = config.features.disable(Feature::Collab); + } +} + +pub(crate) async fn apply_requested_spawn_agent_model_overrides( + session: &Session, + turn: &TurnContext, + config: &mut Config, + requested_model: Option<&str>, + requested_reasoning_effort: Option, +) -> Result<(), FunctionCallError> { + if requested_model.is_none() && requested_reasoning_effort.is_none() { + return Ok(()); + } + + if let Some(requested_model) = requested_model { + let available_models = session + .services + .models_manager + .list_models(RefreshStrategy::Offline) + .await; + let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?; + let selected_model_info = session + .services + .models_manager + .get_model_info(&selected_model_name, config) + .await; + + config.model = Some(selected_model_name.clone()); + if let Some(reasoning_effort) = requested_reasoning_effort { + validate_spawn_agent_reasoning_effort( + &selected_model_name, + &selected_model_info.supported_reasoning_levels, + reasoning_effort, + )?; + config.model_reasoning_effort = Some(reasoning_effort); + } else { + config.model_reasoning_effort = selected_model_info.default_reasoning_level; + } + + return Ok(()); + } + + if let Some(reasoning_effort) = requested_reasoning_effort { + validate_spawn_agent_reasoning_effort( + &turn.model_info.slug, + &turn.model_info.supported_reasoning_levels, + reasoning_effort, + )?; + config.model_reasoning_effort = Some(reasoning_effort); + } + + Ok(()) +} + +fn find_spawn_agent_model_name( + available_models: &[codex_protocol::openai_models::ModelPreset], + requested_model: &str, +) -> Result { + available_models + .iter() + .find(|model| model.model == requested_model) + .map(|model| model.model.clone()) + .ok_or_else(|| { + let available = available_models + .iter() + .map(|model| model.model.as_str()) + .collect::>() + .join(", "); + FunctionCallError::RespondToModel(format!( + "Unknown model `{requested_model}` for spawn_agent. Available models: {available}" + )) + }) +} + +fn validate_spawn_agent_reasoning_effort( + model: &str, + supported_reasoning_levels: &[ReasoningEffortPreset], + requested_reasoning_effort: ReasoningEffort, +) -> Result<(), FunctionCallError> { + if supported_reasoning_levels + .iter() + .any(|preset| preset.effort == requested_reasoning_effort) + { + return Ok(()); + } + + let supported = supported_reasoning_levels + .iter() + .map(|preset| preset.effort.to_string()) + .collect::>() + .join(", "); + Err(FunctionCallError::RespondToModel(format!( + "Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}" + ))) +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index b5ea255d89..f60137f7fe 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -19,9 +19,13 @@ use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use crate::tools::context::ToolOutput; +use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2; +use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; +use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; use crate::turn_diff_tracker::TurnDiffTracker; use codex_features::Feature; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseInputItem; @@ -317,7 +321,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( let session = Arc::new(session); let turn = Arc::new(turn); - let spawn_output = SpawnAgentHandler + let spawn_output = SpawnAgentHandlerV2 .handle(invocation( session.clone(), turn.clone(), @@ -356,7 +360,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( Some("/root/test_process") ); - SendInputHandler + SendInputHandlerV2 .handle(invocation( session.clone(), turn.clone(), @@ -432,7 +436,7 @@ async fn multi_agent_v2_send_input_accepts_structured_items() { let session = Arc::new(session); let turn = Arc::new(turn); - SpawnAgentHandler + SpawnAgentHandlerV2 .handle(invocation( session.clone(), turn.clone(), @@ -467,7 +471,7 @@ async fn multi_agent_v2_send_input_accepts_structured_items() { })), ); - SendInputHandler + SendInputHandlerV2 .handle(invocation) .await .expect("structured items should be accepted in v2"); @@ -557,7 +561,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( let session = Arc::new(session); let turn = Arc::new(turn); - SpawnAgentHandler + SpawnAgentHandlerV2 .handle(invocation( session.clone(), turn.clone(), @@ -594,7 +598,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( ) .await; - SendInputHandler + SendInputHandlerV2 .handle(invocation( session, turn, @@ -688,7 +692,7 @@ async fn multi_agent_v2_spawn_includes_agent_id_key_when_named() { .expect("test config should allow feature update"); turn.config = Arc::new(config); - let output = SpawnAgentHandler + let output = SpawnAgentHandlerV2 .handle(invocation( Arc::new(session), Arc::new(turn), @@ -736,7 +740,7 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() { "task_name": "BadName" })), ); - let Err(err) = SpawnAgentHandler.handle(invocation).await else { + let Err(err) = SpawnAgentHandlerV2.handle(invocation).await else { panic!("invalid agent name should be rejected"); }; assert_eq!( @@ -1355,16 +1359,16 @@ async fn multi_agent_v2_wait_agent_accepts_targets_argument() { "wait_agent", function_payload(json!({"targets": [target.clone()]})), ); - let output = WaitAgentHandler + let output = WaitAgentHandlerV2 .handle(invocation) .await .expect("targets should be accepted in v2 mode"); let (content, success) = expect_text_output(output); - let result: wait::WaitAgentResult = + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = serde_json::from_str(&content).expect("wait_agent result should be json"); assert_eq!( result, - wait::WaitAgentResult { + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { status: HashMap::from([(target, AgentStatus::NotFound)]), timed_out: false, } @@ -1556,7 +1560,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { let session = Arc::new(session); let turn = Arc::new(turn); - let spawn_output = SpawnAgentHandler + let spawn_output = SpawnAgentHandlerV2 .handle(invocation( session.clone(), turn.clone(), @@ -1600,7 +1604,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { .await .expect("shutdown status should arrive"); - let wait_output = WaitAgentHandler + let wait_output = WaitAgentHandlerV2 .handle(invocation( session, turn, @@ -1613,11 +1617,11 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { .await .expect("wait_agent should succeed"); let (content, success) = expect_text_output(wait_output); - let result: wait::WaitAgentResult = + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = serde_json::from_str(&content).expect("wait_agent result should be json"); assert_eq!( result, - wait::WaitAgentResult { + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { status: HashMap::from([(spawn_result.task_name, AgentStatus::Shutdown)]), timed_out: false, } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs new file mode 100644 index 0000000000..931622fa0b --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -0,0 +1,39 @@ +//! Implements the MultiAgentV2 collaboration tool surface. + +use crate::agent::AgentStatus; +use crate::agent::agent_resolver::resolve_agent_target; +use crate::agent::agent_resolver::resolve_agent_targets; +use crate::agent::exceeds_thread_spawn_depth_limit; +use crate::codex::Session; +use crate::function_tool::FunctionCallError; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::multi_agents_common::*; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use async_trait::async_trait; +use codex_protocol::AgentPath; +use codex_protocol::ThreadId; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::CollabAgentInteractionBeginEvent; +use codex_protocol::protocol::CollabAgentInteractionEndEvent; +use codex_protocol::protocol::CollabAgentRef; +use codex_protocol::protocol::CollabAgentSpawnBeginEvent; +use codex_protocol::protocol::CollabAgentSpawnEndEvent; +use codex_protocol::protocol::CollabWaitingBeginEvent; +use codex_protocol::protocol::CollabWaitingEndEvent; +use codex_protocol::user_input::UserInput; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; + +pub(crate) use send_input::Handler as SendInputHandler; +pub(crate) use spawn::Handler as SpawnAgentHandler; +pub(crate) use wait::Handler as WaitAgentHandler; + +mod send_input; +mod spawn; +pub(crate) mod wait; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs new file mode 100644 index 0000000000..8c29a7aec8 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs @@ -0,0 +1,154 @@ +use super::*; +use crate::agent::inter_agent_instruction::InterAgentDelivery; +use crate::agent::inter_agent_instruction::InterAgentInstruction; + +pub(crate) struct Handler; + +fn can_use_v2_inter_agent_instruction(items: &[UserInput]) -> bool { + items + .iter() + .all(|item| matches!(item, UserInput::Text { .. })) +} + +#[async_trait] +impl ToolHandler for Handler { + type Output = SendInputResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SendInputArgs = parse_arguments(&arguments)?; + let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?; + let input_items = parse_collab_input(args.message, args.items)?; + let prompt = input_preview(&input_items); + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or_default(); + if args.interrupt { + session + .services + .agent_control + .interrupt_agent(receiver_thread_id) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; + } + session + .send_event( + &turn, + CollabAgentInteractionBeginEvent { + call_id: call_id.clone(), + sender_thread_id: session.conversation_id, + receiver_thread_id, + prompt: prompt.clone(), + } + .into(), + ) + .await; + let result = if can_use_v2_inter_agent_instruction(&input_items) { + let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { + FunctionCallError::RespondToModel( + "target agent is missing an agent_path".to_string(), + ) + })?; + let instruction = InterAgentInstruction::new( + turn.session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root), + receiver_agent_path, + Vec::new(), + prompt.clone(), + ); + session + .services + .agent_control + .deliver_inter_agent_instruction( + receiver_thread_id, + instruction, + if args.interrupt { + InterAgentDelivery::NextTurn + } else { + InterAgentDelivery::CurrentTurn + }, + ) + .await + } else { + session + .services + .agent_control + .send_input(receiver_thread_id, input_items) + .await + } + .map_err(|err| collab_agent_error(receiver_thread_id, err)); + let status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + session + .send_event( + &turn, + CollabAgentInteractionEndEvent { + call_id, + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + prompt, + status, + } + .into(), + ) + .await; + let submission_id = result?; + + Ok(SendInputResult { submission_id }) + } +} + +#[derive(Debug, Deserialize)] +struct SendInputArgs { + target: String, + message: Option, + items: Option>, + #[serde(default)] + interrupt: bool, +} + +#[derive(Debug, Serialize)] +pub(crate) struct SendInputResult { + submission_id: String, +} + +impl ToolOutput for SendInputResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "send_input") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "send_input") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "send_input") + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs new file mode 100644 index 0000000000..d67be936ec --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -0,0 +1,204 @@ +use super::*; +use crate::agent::control::SpawnAgentOptions; +use crate::agent::next_thread_spawn_depth; +use crate::agent::role::DEFAULT_ROLE_NAME; +use crate::agent::role::apply_role_to_config; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = SpawnAgentResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); + let input_items = parse_collab_input(args.message, args.items)?; + let prompt = input_preview(&input_items); + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); + } + session + .send_event( + &turn, + CollabAgentSpawnBeginEvent { + call_id: call_id.clone(), + sender_thread_id: session.conversation_id, + prompt: prompt.clone(), + model: args.model.clone().unwrap_or_default(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), + } + .into(), + ) + .await; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + apply_role_to_config(&mut config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + apply_spawn_agent_overrides(&mut config, child_depth); + + let result = session + .services + .agent_control + .spawn_agent_with_metadata( + config, + input_items, + Some(thread_spawn_source( + session.conversation_id, + &turn.session_source, + child_depth, + role_name, + args.task_name.clone(), + )?), + SpawnAgentOptions { + fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + }, + ) + .await + .map_err(collab_spawn_error); + let (new_thread_id, new_agent_metadata, status) = match &result { + Ok(spawned_agent) => ( + Some(spawned_agent.thread_id), + Some(spawned_agent.metadata.clone()), + spawned_agent.status.clone(), + ), + Err(_) => (None, None, AgentStatus::NotFound), + }; + let agent_snapshot = match new_thread_id { + Some(thread_id) => { + session + .services + .agent_control + .get_agent_config_snapshot(thread_id) + .await + } + None => None, + }; + let (new_agent_path, new_agent_nickname, new_agent_role) = + match (&agent_snapshot, new_agent_metadata) { + (Some(snapshot), _) => ( + snapshot.session_source.get_agent_path().map(String::from), + snapshot.session_source.get_nickname(), + snapshot.session_source.get_agent_role(), + ), + (None, Some(metadata)) => ( + metadata.agent_path.map(String::from), + metadata.agent_nickname, + metadata.agent_role, + ), + (None, None) => (None, None, None), + }; + let effective_model = agent_snapshot + .as_ref() + .map(|snapshot| snapshot.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()); + let effective_reasoning_effort = agent_snapshot + .as_ref() + .and_then(|snapshot| snapshot.reasoning_effort) + .unwrap_or(args.reasoning_effort.unwrap_or_default()); + let nickname = new_agent_nickname.clone(); + let task_name = new_agent_path.clone(); + session + .send_event( + &turn, + CollabAgentSpawnEndEvent { + call_id, + sender_thread_id: session.conversation_id, + new_thread_id, + new_agent_nickname, + new_agent_role, + prompt, + model: effective_model, + reasoning_effort: effective_reasoning_effort, + status, + } + .into(), + ) + .await; + let new_thread_id = result?.thread_id; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.session_telemetry.counter( + "codex.multi_agent.spawn", + /*inc*/ 1, + &[("role", role_tag)], + ); + + Ok(SpawnAgentResult { + agent_id: task_name.is_none().then(|| new_thread_id.to_string()), + task_name, + nickname, + }) + } +} + +#[derive(Debug, Deserialize)] +struct SpawnAgentArgs { + message: Option, + items: Option>, + task_name: Option, + agent_type: Option, + model: Option, + reasoning_effort: Option, + #[serde(default)] + fork_context: bool, +} + +#[derive(Debug, Serialize)] +pub(crate) struct SpawnAgentResult { + agent_id: Option, + task_name: Option, + nickname: Option, +} + +impl ToolOutput for SpawnAgentResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "spawn_agent") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "spawn_agent") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "spawn_agent") + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs new file mode 100644 index 0000000000..e4bc7d3f4a --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -0,0 +1,236 @@ +use super::*; +use crate::agent::status::is_final; +use futures::FutureExt; +use futures::StreamExt; +use futures::stream::FuturesUnordered; +use std::collections::HashMap; +use std::time::Duration; +use tokio::sync::watch::Receiver; +use tokio::time::Instant; +use tokio::time::timeout_at; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = WaitAgentResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: WaitArgs = parse_arguments(&arguments)?; + let receiver_thread_ids = resolve_agent_targets(&session, &turn, args.targets).await?; + let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len()); + let mut target_by_thread_id = HashMap::with_capacity(receiver_thread_ids.len()); + for receiver_thread_id in &receiver_thread_ids { + let agent_metadata = session + .services + .agent_control + .get_agent_metadata(*receiver_thread_id) + .unwrap_or_default(); + target_by_thread_id.insert( + *receiver_thread_id, + agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| receiver_thread_id.to_string()), + ); + receiver_agents.push(CollabAgentRef { + thread_id: *receiver_thread_id, + agent_nickname: agent_metadata.agent_nickname, + agent_role: agent_metadata.agent_role, + }); + } + + let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); + let timeout_ms = match timeout_ms { + ms if ms <= 0 => { + return Err(FunctionCallError::RespondToModel( + "timeout_ms must be greater than zero".to_owned(), + )); + } + ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS), + }; + + session + .send_event( + &turn, + CollabWaitingBeginEvent { + sender_thread_id: session.conversation_id, + receiver_thread_ids: receiver_thread_ids.clone(), + receiver_agents: receiver_agents.clone(), + call_id: call_id.clone(), + } + .into(), + ) + .await; + + let mut status_rxs = Vec::with_capacity(receiver_thread_ids.len()); + let mut initial_final_statuses = Vec::new(); + for id in &receiver_thread_ids { + match session.services.agent_control.subscribe_status(*id).await { + Ok(rx) => { + let status = rx.borrow().clone(); + if is_final(&status) { + initial_final_statuses.push((*id, status)); + } + status_rxs.push((*id, rx)); + } + Err(crate::error::CodexErr::ThreadNotFound(_)) => { + initial_final_statuses.push((*id, AgentStatus::NotFound)); + } + Err(err) => { + let mut statuses = HashMap::with_capacity(1); + statuses.insert(*id, session.services.agent_control.get_status(*id).await); + session + .send_event( + &turn, + CollabWaitingEndEvent { + sender_thread_id: session.conversation_id, + call_id: call_id.clone(), + agent_statuses: build_wait_agent_statuses( + &statuses, + &receiver_agents, + ), + statuses, + } + .into(), + ) + .await; + return Err(collab_agent_error(*id, err)); + } + } + } + + let statuses = if !initial_final_statuses.is_empty() { + initial_final_statuses + } else { + let mut futures = FuturesUnordered::new(); + for (id, rx) in status_rxs { + let session = session.clone(); + futures.push(wait_for_final_status(session, id, rx)); + } + let mut results = Vec::new(); + let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + loop { + match timeout_at(deadline, futures.next()).await { + Ok(Some(Some(result))) => { + results.push(result); + break; + } + Ok(Some(None)) => continue, + Ok(None) | Err(_) => break, + } + } + if !results.is_empty() { + loop { + match futures.next().now_or_never() { + Some(Some(Some(result))) => results.push(result), + Some(Some(None)) => continue, + Some(None) | None => break, + } + } + } + results + }; + + let timed_out = statuses.is_empty(); + let statuses_by_id = statuses.clone().into_iter().collect::>(); + let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents); + let result = WaitAgentResult { + status: statuses + .into_iter() + .filter_map(|(thread_id, status)| { + target_by_thread_id + .get(&thread_id) + .cloned() + .map(|target| (target, status)) + }) + .collect(), + timed_out, + }; + + session + .send_event( + &turn, + CollabWaitingEndEvent { + sender_thread_id: session.conversation_id, + call_id, + agent_statuses, + statuses: statuses_by_id, + } + .into(), + ) + .await; + + Ok(result) + } +} + +#[derive(Debug, Deserialize)] +struct WaitArgs { + #[serde(default)] + targets: Vec, + timeout_ms: Option, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct WaitAgentResult { + pub(crate) status: HashMap, + pub(crate) timed_out: bool, +} + +impl ToolOutput for WaitAgentResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "wait_agent") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, /*success*/ None, "wait_agent") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "wait_agent") + } +} + +async fn wait_for_final_status( + session: std::sync::Arc, + thread_id: ThreadId, + mut status_rx: Receiver, +) -> Option<(ThreadId, AgentStatus)> { + let mut status = status_rx.borrow().clone(); + if is_final(&status) { + return Some((thread_id, status)); + } + + loop { + if status_rx.changed().await.is_err() { + let latest = session.services.agent_control.get_status(thread_id).await; + return is_final(&latest).then_some((thread_id, latest)); + } + status = status_rx.borrow().clone(); + if is_final(&status) { + return Some((thread_id, status)); + } + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 2e0a413a6f..d8419e1f58 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -23,9 +23,9 @@ use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME; use crate::tools::handlers::agent_jobs::BatchJobHandler; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; -use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; use crate::tools::handlers::request_permissions_tool_description; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; @@ -2602,6 +2602,9 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::multi_agents::SendInputHandler; use crate::tools::handlers::multi_agents::SpawnAgentHandler; use crate::tools::handlers::multi_agents::WaitAgentHandler; + use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2; + use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; + use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; use std::sync::Arc; let mut builder = ToolRegistryBuilder::new(); @@ -3013,9 +3016,15 @@ pub(crate) fn build_specs_with_discoverable_tools( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler)); - builder.register_handler("send_input", Arc::new(SendInputHandler)); - builder.register_handler("wait_agent", Arc::new(WaitAgentHandler)); + if config.multi_agent_v2 { + builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2)); + builder.register_handler("send_input", Arc::new(SendInputHandlerV2)); + builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2)); + } else { + builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler)); + builder.register_handler("send_input", Arc::new(SendInputHandler)); + builder.register_handler("wait_agent", Arc::new(WaitAgentHandler)); + } builder.register_handler("close_agent", Arc::new(CloseAgentHandler)); } From 332edba78e6d3bdccba709801a54f55b835136e8 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Mon, 23 Mar 2026 12:46:49 -0700 Subject: [PATCH 21/38] Thread guardian Responses API errors into denial rationale (#15516) ## Summary - capture the last guardian `EventMsg::Error` while waiting for review completion - reuse that error as the denial rationale when the review turn completes without an assessment payload - add a regression test for the `/responses` HTTP 400 path ## Testing - `just fmt` - `cargo test -p codex-core guardian_review_surfaces_responses_api_errors_in_rejection_reason` - `just argument-comment-lint -p codex-core` ## Notes - `cargo test -p codex-core` still fails on the pre-existing unrelated test `tools::js_repl::tests::js_repl_imported_local_files_can_access_repl_globals` in this environment (`mktemp ... Operation not permitted` while downloading `dotslash`) Co-authored-by: Codex --- codex-rs/core/src/guardian/review_session.rs | 12 +++ codex-rs/core/src/guardian/tests.rs | 95 ++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 34f0b6298e..729e172387 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -577,6 +577,7 @@ async fn wait_for_guardian_review( ) -> (GuardianReviewSessionOutcome, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); + let mut last_error_message: Option = None; loop { tokio::select! { @@ -598,11 +599,22 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + if turn_complete.last_agent_message.is_none() + && let Some(error_message) = last_error_message + { + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), + true, + ); + } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, ); } + EventMsg::Error(error) => { + last_error_message = Some(error.message); + } EventMsg::TurnAborted(_) => { return (GuardianReviewSessionOutcome::Aborted, true); } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index e1595ea167..89c22528e8 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -31,6 +31,7 @@ use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_response_once; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -717,6 +718,100 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let error_message = + "Item 'rs_test' of type 'reasoning' was provided without its required following item."; + let _request_log = mount_response_once( + &server, + wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": { + "message": error_message, + "type": "invalid_request_error", + "param": "input" + } + })), + ) + .await; + + let (mut session, mut turn, rx) = crate::codex::make_session_and_context_with_rx().await; + let mut config = (*turn.config).clone(); + config.model_provider.base_url = Some(format!("{}/v1", server.uri())); + config.user_instructions = None; + let config = Arc::new(config); + let models_manager = Arc::new(test_support::models_manager_with_provider( + config.codex_home.clone(), + Arc::clone(&session.services.auth_manager), + config.model_provider.clone(), + )); + Arc::get_mut(&mut session) + .expect("session should be uniquely owned") + .services + .models_manager = models_manager; + let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned"); + turn_mut.config = Arc::clone(&config); + turn_mut.provider = config.model_provider.clone(); + turn_mut.user_instructions = None; + + seed_guardian_parent_history(&session, &turn).await; + + let decision = review_approval_request( + &session, + &turn, + GuardianApprovalRequest::Shell { + id: "shell-guardian-error".to_string(), + command: vec!["git".to_string(), "push".to_string()], + cwd: PathBuf::from("/repo/codex-rs/core"), + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + justification: Some("Need to push the reviewed docs fix.".to_string()), + }, + None, + ) + .await; + + assert_eq!(decision, ReviewDecision::Denied); + + let mut warnings = Vec::new(); + let mut denial_rationales = Vec::new(); + while let Ok(event) = rx.try_recv() { + match event.msg { + EventMsg::Warning(event) => warnings.push(event.message), + EventMsg::GuardianAssessment(event) + if event.status == GuardianAssessmentStatus::Denied => + { + denial_rationales.push(event.rationale) + } + _ => {} + } + } + + assert!( + warnings + .iter() + .any(|message| message.contains(error_message)), + "warning should include the underlying responses api error" + ); + assert!( + denial_rationales + .iter() + .flatten() + .any(|message| message.contains(error_message)), + "denial rationale should include the underlying responses api error" + ); + assert!( + denial_rationales.iter().flatten().all(|message| { + !message.contains("guardian review completed without an assessment payload") + }), + "denial rationale should not fall back to the generic missing payload error" + ); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> { let first_assessment = serde_json::json!({ From 9a33e5c0a08e67b85ab80125d62bbe359c06cdd3 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Mon, 23 Mar 2026 12:57:40 -0700 Subject: [PATCH 22/38] feat: support disable skills by name. (#15378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support disabling skills by name, primarily for plugin skills. We can’t use the path, since plugin skill paths may change across versions. --- .../schema/json/ClientRequest.json | 20 +- .../codex_app_server_protocol.schemas.json | 24 ++- .../codex_app_server_protocol.v2.schemas.json | 24 ++- .../schema/json/v2/PluginReadResponse.json | 4 + .../json/v2/SkillsConfigWriteParams.json | 26 ++- .../schema/typescript/v2/SkillSummary.ts | 2 +- .../typescript/v2/SkillsConfigWriteParams.ts | 11 +- .../app-server-protocol/src/protocol/v2.rs | 8 +- codex-rs/app-server/README.md | 23 +- .../app-server/src/codex_message_processor.rs | 35 ++- .../app-server/tests/suite/v2/plugin_read.rs | 5 + codex-rs/core/config.schema.json | 14 +- codex-rs/core/src/config/edit.rs | 71 +++++-- codex-rs/core/src/config/edit_tests.rs | 21 ++ codex-rs/core/src/config/types.rs | 7 +- codex-rs/core/src/config/types_tests.rs | 41 ++++ codex-rs/core/src/plugins/manager.rs | 151 ++++++++++--- codex-rs/core/src/plugins/manager_tests.rs | 162 +++++++++++++- codex-rs/core/src/plugins/store.rs | 11 +- codex-rs/core/src/plugins/store_tests.rs | 44 ++++ codex-rs/core/src/skills/config_rules.rs | 135 ++++++++++++ codex-rs/core/src/skills/manager.rs | 83 ++------ codex-rs/core/src/skills/manager_tests.rs | 199 +++++++++++++++++- codex-rs/core/src/skills/mod.rs | 1 + 24 files changed, 983 insertions(+), 139 deletions(-) create mode 100644 codex-rs/core/src/skills/config_rules.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index ae8e6fed34..903b26b80b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2343,13 +2343,27 @@ "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "type": "object" }, 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 6f71247511..6b731a9b50 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 @@ -11352,6 +11352,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "interface": { "anyOf": [ { @@ -11377,6 +11380,7 @@ }, "required": [ "description", + "enabled", "name", "path" ], @@ -11433,13 +11437,27 @@ "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "title": "SkillsConfigWriteParams", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index a9c3b96635..0d69834f15 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9112,6 +9112,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "interface": { "anyOf": [ { @@ -9137,6 +9140,7 @@ }, "required": [ "description", + "enabled", "name", "path" ], @@ -9193,13 +9197,27 @@ "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "title": "SkillsConfigWriteParams", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 5fecf50376..1917935a2e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -318,6 +318,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "interface": { "anyOf": [ { @@ -343,6 +346,7 @@ }, "required": [ "description", + "enabled", "name", "path" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json index 3fa74811d5..696226a50c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json @@ -1,16 +1,36 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, "properties": { "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "title": "SkillsConfigWriteParams", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts index 818e0b05d4..ea37393536 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SkillInterface } from "./SkillInterface"; -export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: string, }; +export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: string, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts index 5a4bcf9bc0..273d593cfd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts @@ -1,5 +1,14 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type SkillsConfigWriteParams = { path: string, enabled: boolean, }; +export type SkillsConfigWriteParams = { +/** + * Path-based selector. + */ +path?: AbsolutePathBuf | null, +/** + * Name-based selector. + */ +name?: string | null, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a1d5e55621..e270d8ad61 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3340,6 +3340,7 @@ pub struct SkillSummary { pub short_description: Option, pub interface: Option, pub path: PathBuf, + pub enabled: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -3378,7 +3379,12 @@ pub enum PluginSource { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct SkillsConfigWriteParams { - pub path: PathBuf, + /// Path-based selector. + #[ts(optional = nullable)] + pub path: Option, + /// Name-based selector. + #[ts(optional = nullable)] + pub name: Option, pub enabled: bool, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4d139b7f7e..ace7f2bd43 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -164,10 +164,10 @@ Example with notification opt-out: - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). -- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). +- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. -- `skills/config/write` — write user-level skill config by path. +- `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). - `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. @@ -1145,14 +1145,29 @@ The server also emits `skills/changed` notifications when watched local skill fi } ``` -To enable or disable a skill by path: +To enable or disable a skill by absolute path: ```json { "method": "skills/config/write", "id": 26, "params": { - "path": "/Users/me/.codex/skills/skill-creator/SKILL.md", + "path": "/Users/alice/.codex/skills/skill-creator/SKILL.md", + "name": null, + "enabled": false + } +} +``` + +To enable or disable a skill by name: + +```json +{ + "method": "skills/config/write", + "id": 27, + "params": { + "path": null, + "name": "github:yeet", "enabled": false } } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8917f392dd..947a49ff2f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5672,7 +5672,7 @@ impl CodexMessageProcessor { interface: outcome.plugin.interface.map(plugin_interface_to_info), }, description: outcome.plugin.description, - skills: plugin_skills_to_info(&visible_skills), + skills: plugin_skills_to_info(&visible_skills, &outcome.plugin.disabled_skill_paths), apps: app_summaries, mcp_servers: outcome.plugin.mcp_server_names, }; @@ -5687,8 +5687,30 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: SkillsConfigWriteParams, ) { - let SkillsConfigWriteParams { path, enabled } = params; - let edits = vec![ConfigEdit::SetSkillConfig { path, enabled }]; + let SkillsConfigWriteParams { + path, + name, + enabled, + } = params; + let edit = match (path, name) { + (Some(path), None) => ConfigEdit::SetSkillConfig { + path: path.into_path_buf(), + enabled, + }, + (None, Some(name)) if !name.trim().is_empty() => { + ConfigEdit::SetSkillConfigByName { name, enabled } + } + _ => { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: "skills/config/write requires exactly one of path or name".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let edits = vec![edit]; let result = ConfigEditsBuilder::new(&self.config.codex_home) .with_edits(edits) .apply() @@ -5696,6 +5718,7 @@ impl CodexMessageProcessor { match result { Ok(()) => { + self.thread_manager.plugins_manager().clear_cache(); self.thread_manager.skills_manager().clear_cache(); self.outgoing .send_response( @@ -7669,7 +7692,10 @@ fn skills_to_info( .collect() } -fn plugin_skills_to_info(skills: &[codex_core::skills::SkillMetadata]) -> Vec { +fn plugin_skills_to_info( + skills: &[codex_core::skills::SkillMetadata], + disabled_skill_paths: &std::collections::HashSet, +) -> Vec { skills .iter() .map(|skill| SkillSummary { @@ -7687,6 +7713,7 @@ fn plugin_skills_to_info(skills: &[codex_core::skills::SkillMetadata]) -> Vec), - /// Set or clear a skill config entry under `[[skills.config]]`. + /// Set or clear a skill config entry under `[[skills.config]]` by path. SetSkillConfig { path: PathBuf, enabled: bool }, + /// Set or clear a skill config entry under `[[skills.config]]` by name. + SetSkillConfigByName { name: String, enabled: bool }, /// Set trust_level under `[projects.""]`, /// migrating inline tables to explicit tables. SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, @@ -60,6 +62,12 @@ pub enum ConfigEdit { ClearPath { segments: Vec }, } +#[derive(Clone, Debug, PartialEq, Eq)] +enum SkillConfigSelector { + Name(String), + Path(PathBuf), +} + /// Produces a config edit that sets `[tui].theme = ""`. pub fn syntax_theme_edit(name: &str) -> ConfigEdit { ConfigEdit::SetPath { @@ -387,7 +395,10 @@ impl ConfigDocument { )), ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)), ConfigEdit::SetSkillConfig { path, enabled } => { - Ok(self.set_skill_config(path.as_path(), *enabled)) + Ok(self.set_skill_config(SkillConfigSelector::Path(path.clone()), *enabled)) + } + ConfigEdit::SetSkillConfigByName { name, enabled } => { + Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled)) } ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), @@ -478,8 +489,16 @@ impl ConfigDocument { true } - fn set_skill_config(&mut self, path: &Path, enabled: bool) -> bool { - let normalized_path = normalize_skill_config_path(path); + fn set_skill_config(&mut self, selector: SkillConfigSelector, enabled: bool) -> bool { + let selector = match selector { + SkillConfigSelector::Name(name) => SkillConfigSelector::Name(name.trim().to_string()), + SkillConfigSelector::Path(path) => { + SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(&path))) + } + }; + if matches!(&selector, SkillConfigSelector::Name(name) if name.is_empty()) { + return false; + } let mut remove_skills_table = false; let mut mutated = false; @@ -538,12 +557,8 @@ impl ConfigDocument { }; let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { - table - .get("path") - .and_then(|item| item.as_str()) - .map(Path::new) - .map(normalize_skill_config_path) - .filter(|value| *value == normalized_path) + skill_config_selector_from_table(table) + .filter(|value| value == &selector) .map(|_| idx) }); @@ -561,7 +576,7 @@ impl ConfigDocument { } else if let Some(index) = existing_index { for (idx, table) in overrides.iter_mut().enumerate() { if idx == index { - table["path"] = value(normalized_path); + write_skill_config_selector(table, &selector); table["enabled"] = value(false); mutated = true; break; @@ -570,7 +585,7 @@ impl ConfigDocument { } else { let mut entry = TomlTable::new(); entry.set_implicit(false); - entry["path"] = value(normalized_path); + write_skill_config_selector(&mut entry, &selector); entry["enabled"] = value(false); overrides.push(entry); mutated = true; @@ -699,6 +714,38 @@ fn normalize_skill_config_path(path: &Path) -> String { .to_string() } +fn skill_config_selector_from_table(table: &TomlTable) -> Option { + let path = table + .get("path") + .and_then(|item| item.as_str()) + .map(Path::new) + .map(|path| SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(path)))); + let name = table + .get("name") + .and_then(|item| item.as_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| SkillConfigSelector::Name(name.to_string())); + + match (path, name) { + (Some(selector), None) | (None, Some(selector)) => Some(selector), + _ => None, + } +} + +fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSelector) { + match selector { + SkillConfigSelector::Name(name) => { + table.remove("path"); + table["name"] = value(name.clone()); + } + SkillConfigSelector::Path(path) => { + table.remove("name"); + table["path"] = value(path.to_string_lossy().to_string()); + } + } +} + /// Persist edits using a blocking strategy. pub fn apply_blocking( codex_home: &Path, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 5a31d84dd0..632716f00d 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -110,6 +110,27 @@ enabled = false assert_eq!(contents, ""); } +#[test] +fn set_skill_config_writes_name_selector_entry() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetSkillConfigByName { + name: "github:yeet".to_string(), + enabled: false, + }]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[[skills.config]] +name = "github:yeet" +enabled = false +"#; + assert_eq!(contents, expected); +} + #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 3b20779cd5..7a94700ce9 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -804,7 +804,12 @@ impl Notice { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct SkillConfig { - pub path: AbsolutePathBuf, + /// Path-based selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Name-based selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, pub enabled: bool, } diff --git a/codex-rs/core/src/config/types_tests.rs b/codex-rs/core/src/config/types_tests.rs index adb65e1673..dde0cd18fc 100644 --- a/codex-rs/core/src/config/types_tests.rs +++ b/codex-rs/core/src/config/types_tests.rs @@ -243,6 +243,47 @@ fn deserialize_server_config_with_tool_filters() { assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()])); } +#[test] +fn deserialize_skill_config_with_name_selector() { + let cfg: SkillConfig = toml::from_str( + r#" + name = "github:yeet" + enabled = false + "#, + ) + .expect("should deserialize skill config with name selector"); + + assert_eq!(cfg.name.as_deref(), Some("github:yeet")); + assert_eq!(cfg.path, None); + assert!(!cfg.enabled); +} + +#[test] +fn deserialize_skill_config_with_path_selector() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let cfg: SkillConfig = toml::from_str(&format!( + r#" + path = {path:?} + enabled = false + "#, + path = skill_path.display().to_string(), + )) + .expect("should deserialize skill config with path selector"); + + assert_eq!( + cfg, + SkillConfig { + path: Some( + AbsolutePathBuf::from_absolute_path(&skill_path) + .expect("skill path should be absolute"), + ), + name: None, + enabled: false, + } + ); +} + #[test] fn deserialize_rejects_command_and_url() { toml::from_str::( diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index f02758d273..571535b5f0 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -19,7 +19,6 @@ use super::remote::fetch_remote_featured_plugin_ids; use super::remote::fetch_remote_plugin_status; use super::remote::uninstall_remote_plugin; use super::startup_sync::start_startup_remote_plugin_sync_once; -use super::store::DEFAULT_PLUGIN_VERSION; use super::store::PluginId; use super::store::PluginIdError; use super::store::PluginInstallResult as StorePluginInstallResult; @@ -38,6 +37,9 @@ use crate::config::types::McpServerConfig; use crate::config::types::PluginConfig; use crate::config_loader::ConfigLayerStack; use crate::skills::SkillMetadata; +use crate::skills::config_rules::SkillConfigRules; +use crate::skills::config_rules::resolve_disabled_skill_paths; +use crate::skills::config_rules::skill_config_rules_from_stack; use crate::skills::loader::SkillRoot; use crate::skills::loader::load_skills_from_roots; use codex_app_server_protocol::ConfigValueWriteParams; @@ -152,6 +154,7 @@ pub struct PluginDetail { pub installed: bool, pub enabled: bool, pub skills: Vec, + pub disabled_skill_paths: HashSet, pub apps: Vec, pub mcp_server_names: Vec, } @@ -183,6 +186,8 @@ pub struct LoadedPlugin { pub root: AbsolutePathBuf, pub enabled: bool, pub skill_roots: Vec, + pub disabled_skill_paths: HashSet, + pub has_enabled_skills: bool, pub mcp_servers: HashMap, pub apps: Vec, pub error: Option, @@ -235,7 +240,7 @@ impl PluginCapabilitySummary { .clone() .unwrap_or_else(|| plugin.config_name.clone()), description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()), - has_skills: !plugin.skill_roots.is_empty(), + has_skills: plugin.has_enabled_skills, mcp_server_names, app_connector_ids: plugin.apps.clone(), }; @@ -258,11 +263,16 @@ impl PluginCapabilitySummary { impl From for PluginCapabilitySummary { fn from(value: PluginDetail) -> Self { + let has_skills = value.skills.iter().any(|skill| { + !value + .disabled_skill_paths + .contains(&skill.path_to_skills_md) + }); Self { config_name: value.id, display_name: value.name, description: prompt_safe_plugin_description(value.description.as_deref()), - has_skills: !value.skills.is_empty(), + has_skills, mcp_server_names: value.mcp_server_names, app_connector_ids: value.apps, } @@ -531,7 +541,11 @@ impl PluginsManager { return outcome; } - let outcome = load_plugins_from_layer_stack(&config.config_layer_stack, &self.store); + let outcome = load_plugins_from_layer_stack( + &config.config_layer_stack, + &self.store, + self.restriction_product, + ); log_plugin_load_errors(&outcome); let mut cache = match self.cached_enabled_outcome.write() { Ok(cache) => cache, @@ -1070,6 +1084,11 @@ impl PluginsManager { let source_path = match &plugin.source { MarketplacePluginSource::Local { path } => path.clone(), }; + if !source_path.as_path().is_dir() { + return Err(MarketplaceError::InvalidPlugin( + "path does not exist or is not a directory".to_string(), + )); + } let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| { MarketplaceError::InvalidPlugin( "missing or invalid .codex-plugin/plugin.json".to_string(), @@ -1077,15 +1096,13 @@ impl PluginsManager { })?; let description = manifest.description.clone(); let manifest_paths = &manifest.paths; - let skill_roots = plugin_skill_roots(source_path.as_path(), manifest_paths); - let skills = load_skills_from_roots(skill_roots.into_iter().map(|path| SkillRoot { - path, - scope: SkillScope::User, - })) - .skills - .into_iter() - .filter(|skill| skill.matches_product_restriction_for_product(self.restriction_product)) - .collect(); + let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack); + let resolved_skills = load_plugin_skills( + source_path.as_path(), + manifest_paths, + self.restriction_product, + &skill_config_rules, + ); let apps = load_plugin_apps(source_path.as_path()); let mcp_config_paths = plugin_mcp_config_paths(source_path.as_path(), manifest_paths); let mut mcp_server_names = Vec::new(); @@ -1111,7 +1128,8 @@ impl PluginsManager { interface: plugin.interface, installed: installed_plugins.contains(&plugin_key), enabled: enabled_plugins.contains(&plugin_key), - skills, + skills: resolved_skills.skills, + disabled_skill_paths: resolved_skills.disabled_skill_paths, apps, mcp_server_names, }, @@ -1347,7 +1365,9 @@ struct PluginAppConfig { pub(crate) fn load_plugins_from_layer_stack( config_layer_stack: &ConfigLayerStack, store: &PluginStore, + restriction_product: Option, ) -> PluginLoadOutcome { + let skill_config_rules = skill_config_rules_from_stack(config_layer_stack); let mut configured_plugins: Vec<_> = configured_plugins_from_stack(config_layer_stack) .into_iter() .collect(); @@ -1356,7 +1376,13 @@ pub(crate) fn load_plugins_from_layer_stack( let mut plugins = Vec::with_capacity(configured_plugins.len()); let mut seen_mcp_server_names = HashMap::::new(); for (configured_name, plugin) in configured_plugins { - let loaded_plugin = load_plugin(configured_name.clone(), &plugin, store); + let loaded_plugin = load_plugin( + configured_name.clone(), + &plugin, + store, + restriction_product, + &skill_config_rules, + ); for name in loaded_plugin.mcp_servers.keys() { if let Some(previous_plugin) = seen_mcp_server_names.insert(name.clone(), configured_name.clone()) @@ -1463,16 +1489,24 @@ fn configured_plugins_from_stack( } } -fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) -> LoadedPlugin { - let plugin_root = PluginId::parse(&config_name).map(|plugin_id| { - store - .active_plugin_root(&plugin_id) - .unwrap_or_else(|| store.plugin_root(&plugin_id, DEFAULT_PLUGIN_VERSION)) - }); - let root = match &plugin_root { - Ok(plugin_root) => plugin_root.clone(), - Err(_) => store.root().clone(), - }; +fn load_plugin( + config_name: String, + plugin: &PluginConfig, + store: &PluginStore, + restriction_product: Option, + skill_config_rules: &SkillConfigRules, +) -> LoadedPlugin { + let plugin_id = PluginId::parse(&config_name); + let active_plugin_root = plugin_id + .as_ref() + .ok() + .and_then(|plugin_id| store.active_plugin_root(plugin_id)); + let root = active_plugin_root + .clone() + .unwrap_or_else(|| match &plugin_id { + Ok(plugin_id) => store.plugin_base_root(plugin_id), + Err(_) => store.root().clone(), + }); let mut loaded_plugin = LoadedPlugin { config_name, manifest_name: None, @@ -1480,6 +1514,8 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) root, enabled: plugin.enabled, skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, mcp_servers: HashMap::new(), apps: Vec::new(), error: None, @@ -1489,8 +1525,14 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) return loaded_plugin; } - let plugin_root = match plugin_root { - Ok(plugin_root) => plugin_root, + let plugin_root = match plugin_id { + Ok(_) => match active_plugin_root { + Some(plugin_root) => plugin_root, + None => { + loaded_plugin.error = Some("plugin is not installed".to_string()); + return loaded_plugin; + } + }, Err(err) => { loaded_plugin.error = Some(err.to_string()); return loaded_plugin; @@ -1511,6 +1553,15 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) loaded_plugin.manifest_name = Some(manifest.name.clone()); loaded_plugin.manifest_description = manifest.description.clone(); loaded_plugin.skill_roots = plugin_skill_roots(plugin_root.as_path(), manifest_paths); + let resolved_skills = load_plugin_skills( + plugin_root.as_path(), + manifest_paths, + restriction_product, + skill_config_rules, + ); + let has_enabled_skills = resolved_skills.has_enabled_skills(); + loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths; + loaded_plugin.has_enabled_skills = has_enabled_skills; let mut mcp_servers = HashMap::new(); for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path); @@ -1530,6 +1581,52 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) loaded_plugin } +struct ResolvedPluginSkills { + skills: Vec, + disabled_skill_paths: HashSet, + had_errors: bool, +} + +impl ResolvedPluginSkills { + fn has_enabled_skills(&self) -> bool { + // Keep the plugin visible in capability summaries if skill loading was partial. + self.had_errors + || self + .skills + .iter() + .any(|skill| !self.disabled_skill_paths.contains(&skill.path_to_skills_md)) + } +} + +fn load_plugin_skills( + plugin_root: &Path, + manifest_paths: &PluginManifestPaths, + restriction_product: Option, + skill_config_rules: &SkillConfigRules, +) -> ResolvedPluginSkills { + let outcome = load_skills_from_roots( + plugin_skill_roots(plugin_root, manifest_paths) + .into_iter() + .map(|path| SkillRoot { + path, + scope: SkillScope::User, + }), + ); + let had_errors = !outcome.errors.is_empty(); + let skills = outcome + .skills + .into_iter() + .filter(|skill| skill.matches_product_restriction_for_product(restriction_product)) + .collect::>(); + let disabled_skill_paths = resolve_disabled_skill_paths(&skills, skill_config_rules); + + ResolvedPluginSkills { + skills, + disabled_skill_paths, + had_errors, + } +} + fn plugin_skill_roots(plugin_root: &Path, manifest_paths: &PluginManifestPaths) -> Vec { let mut paths = default_skill_roots(plugin_root); if let Some(path) = &manifest_paths.skills { diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index cd8541a85c..630808dc0e 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -13,6 +13,7 @@ use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; use codex_app_server_protocol::ConfigLayerSource; +use codex_protocol::protocol::Product; use pretty_assertions::assert_eq; use std::fs; use tempfile::TempDir; @@ -139,6 +140,8 @@ fn load_plugins_loads_default_skills_and_mcp_servers() { root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), enabled: true, skill_roots: vec![plugin_root.join("skills")], + disabled_skill_paths: HashSet::new(), + has_enabled_skills: true, mcp_servers: HashMap::from([( "sample".to_string(), McpServerConfig { @@ -185,6 +188,89 @@ fn load_plugins_loads_default_skills_and_mcp_servers() { ); } +#[test] +fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + let skill_path = plugin_root.join("skills/sample-search/SKILL.md"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &skill_path, + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + + let config_toml = r#"[features] +plugins = true + +[[skills.config]] +name = "sample:sample-search" +enabled = false + +[plugins."sample@test"] +enabled = true +"#; + let outcome = load_plugins_from_config(config_toml, codex_home.path()); + let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize"); + + assert_eq!( + outcome.plugins[0].disabled_skill_paths, + HashSet::from([skill_path]) + ); + assert!(!outcome.plugins[0].has_enabled_skills); + assert!(outcome.capability_summaries().is_empty()); +} + +#[test] +fn load_plugins_ignores_unknown_disabled_skill_names() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + + let config_toml = r#"[features] +plugins = true + +[[skills.config]] +name = "sample:missing-skill" +enabled = false + +[plugins."sample@test"] +enabled = true +"#; + let outcome = load_plugins_from_config(config_toml, codex_home.path()); + + assert!(outcome.plugins[0].disabled_skill_paths.is_empty()); + assert!(outcome.plugins[0].has_enabled_skills); + assert_eq!( + outcome.capability_summaries(), + &[PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }] + ); +} + #[test] fn plugin_telemetry_metadata_uses_default_mcp_config_path() { let codex_home = TempDir::new().unwrap(); @@ -540,6 +626,8 @@ fn load_plugins_preserves_disabled_plugins_without_effective_contributions() { root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, mcp_servers: HashMap::new(), apps: Vec::new(), error: None, @@ -651,6 +739,8 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, mcp_servers: HashMap::new(), apps: Vec::new(), error: None, @@ -664,6 +754,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { let outcome = PluginLoadOutcome::from_plugins(vec![ LoadedPlugin { skill_roots: vec![codex_home.path().join("skills-plugin/skills")], + has_enabled_skills: true, ..plugin("skills@test", "skills-plugin", "skills-plugin") }, LoadedPlugin { @@ -1166,6 +1257,70 @@ enabled = true assert!(matches!(err, MarketplaceError::PluginsDisabled)); } +#[tokio::test] +async fn read_plugin_for_config_uses_user_layer_skill_settings_only() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("enabled-plugin"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "enabled-plugin", + "source": { + "source": "local", + "path": "./enabled-plugin" + } + } + ] +}"#, + ); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"enabled-plugin"}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."enabled-plugin@debug"] +enabled = true +"#, + ); + write_file( + &repo_root.join(".codex/config.toml"), + r#"[[skills.config]] +name = "enabled-plugin:sample-search" +enabled = false +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let outcome = PluginsManager::new(tmp.path().to_path_buf()) + .read_plugin_for_config( + &config, + &PluginReadRequest { + plugin_name: "enabled-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }, + ) + .unwrap(); + + assert!(outcome.plugin.disabled_skill_paths.is_empty()); +} + #[tokio::test] async fn sync_plugins_from_remote_returns_default_when_feature_disabled() { let tmp = tempfile::tempdir().unwrap(); @@ -2082,8 +2237,11 @@ fn load_plugins_ignores_project_config_files() { ) .expect("config layer stack should build"); - let outcome = - load_plugins_from_layer_stack(&stack, &PluginStore::new(codex_home.path().to_path_buf())); + let outcome = load_plugins_from_layer_stack( + &stack, + &PluginStore::new(codex_home.path().to_path_buf()), + Some(Product::Codex), + ); assert_eq!(outcome, PluginLoadOutcome::default()); } diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index 262ca10286..97b0ccefe1 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -110,10 +110,15 @@ impl PluginStore { .filter(|version| validate_plugin_segment(version, "plugin version").is_ok()) .collect::>(); discovered_versions.sort_unstable(); - if discovered_versions.len() == 1 { - discovered_versions.pop() - } else { + if discovered_versions.is_empty() { None + } else if discovered_versions + .iter() + .any(|version| version == DEFAULT_PLUGIN_VERSION) + { + Some(DEFAULT_PLUGIN_VERSION.to_string()) + } else { + discovered_versions.pop() } } diff --git a/codex-rs/core/src/plugins/store_tests.rs b/codex-rs/core/src/plugins/store_tests.rs index b1da11a8a6..61c3460255 100644 --- a/codex-rs/core/src/plugins/store_tests.rs +++ b/codex-rs/core/src/plugins/store_tests.rs @@ -130,6 +130,50 @@ fn active_plugin_version_reads_version_directory_name() { ); } +#[test] +fn active_plugin_version_prefers_default_local_version_when_multiple_versions_exist() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/0123456789abcdef", + "sample-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "sample-plugin", + ); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("local".to_string()) + ); +} + +#[test] +fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/0123456789abcdef", + "sample-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/fedcba9876543210", + "sample-plugin", + ); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("fedcba9876543210".to_string()) + ); +} + #[test] fn plugin_root_rejects_path_separators_in_key_segments() { let err = PluginId::parse("../../etc@debug").unwrap_err(); diff --git a/codex-rs/core/src/skills/config_rules.rs b/codex-rs/core/src/skills/config_rules.rs new file mode 100644 index 0000000000..fa1be878bb --- /dev/null +++ b/codex-rs/core/src/skills/config_rules.rs @@ -0,0 +1,135 @@ +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; + +use codex_app_server_protocol::ConfigLayerSource; +use tracing::warn; + +use crate::config::types::SkillConfig; +use crate::config::types::SkillsConfig; +use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigLayerStackOrdering; +use crate::skills::SkillMetadata; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) enum SkillConfigRuleSelector { + Name(String), + Path(PathBuf), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct SkillConfigRule { + pub selector: SkillConfigRuleSelector, + pub enabled: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub(crate) struct SkillConfigRules { + pub entries: Vec, +} + +pub(crate) fn skill_config_rules_from_stack( + config_layer_stack: &ConfigLayerStack, +) -> SkillConfigRules { + let mut entries = Vec::new(); + for layer in config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) { + if !matches!( + layer.name, + ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags + ) { + continue; + } + + let Some(skills_value) = layer.config.get("skills") else { + continue; + }; + let skills: SkillsConfig = match skills_value.clone().try_into() { + Ok(skills) => skills, + Err(err) => { + warn!("invalid skills config: {err}"); + continue; + } + }; + + for entry in skills.config { + let Some(selector) = skill_config_rule_selector(&entry) else { + continue; + }; + // Preserve layer order so a later name selector can override an earlier path selector + // for the same loaded skill. + entries.retain(|entry: &SkillConfigRule| entry.selector != selector); + entries.push(SkillConfigRule { + selector, + enabled: entry.enabled, + }); + } + } + + SkillConfigRules { entries } +} + +pub(crate) fn resolve_disabled_skill_paths( + skills: &[SkillMetadata], + rules: &SkillConfigRules, +) -> HashSet { + let mut disabled_paths = HashSet::new(); + + for entry in &rules.entries { + match &entry.selector { + SkillConfigRuleSelector::Path(path) => { + if entry.enabled { + disabled_paths.remove(path); + } else { + disabled_paths.insert(path.clone()); + } + } + SkillConfigRuleSelector::Name(name) => { + for path in skills + .iter() + .filter(|skill| skill.name == *name) + .map(|skill| skill.path_to_skills_md.clone()) + { + if entry.enabled { + disabled_paths.remove(&path); + } else { + disabled_paths.insert(path); + } + } + } + } + } + + disabled_paths +} + +fn skill_config_rule_selector(entry: &SkillConfig) -> Option { + match (entry.path.as_ref(), entry.name.as_deref()) { + (Some(path), None) => Some(SkillConfigRuleSelector::Path(normalize_rule_path( + path.as_path(), + ))), + (None, Some(name)) => { + let name = name.trim(); + if name.is_empty() { + warn!("ignoring empty skills.config name override"); + None + } else { + Some(SkillConfigRuleSelector::Name(name.to_string())) + } + } + (Some(_), Some(_)) => { + warn!("ignoring skills.config entry with both path and name selectors"); + None + } + (None, None) => { + warn!("ignoring skills.config entry without a path or name selector"); + None + } + } +} + +fn normalize_rule_path(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs index 982780f821..446580b6c7 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core/src/skills/manager.rs @@ -5,7 +5,6 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; -use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; @@ -16,12 +15,14 @@ use tracing::warn; use crate::config::Config; use crate::config::types::SkillsConfig; use crate::config_loader::CloudRequirementsLoader; -use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; use crate::plugins::PluginsManager; use crate::skills::SkillLoadOutcome; use crate::skills::build_implicit_skill_path_indexes; +use crate::skills::config_rules::SkillConfigRules; +use crate::skills::config_rules::resolve_disabled_skill_paths; +use crate::skills::config_rules::skill_config_rules_from_stack; use crate::skills::loader::SkillRoot; use crate::skills::loader::load_skills_from_roots; use crate::skills::loader::skill_roots; @@ -81,15 +82,13 @@ impl SkillsManager { /// to share a directory. pub fn skills_for_config(&self, config: &Config) -> SkillLoadOutcome { let roots = self.skill_roots_for_config(config); - let cache_key = config_skills_cache_key(&roots, &config.config_layer_stack); + let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack); + let cache_key = config_skills_cache_key(&roots, &skill_config_rules); if let Some(outcome) = self.cached_outcome_for_config(&cache_key) { return outcome; } - let outcome = crate::skills::filter_skill_load_outcome_for_product( - finalize_skill_outcome(load_skills_from_roots(roots), &config.config_layer_stack), - self.restriction_product, - ); + let outcome = self.build_skill_outcome(roots, &skill_config_rules); let mut cache = self .cache_by_config .write() @@ -192,7 +191,8 @@ impl SkillsManager { scope: SkillScope::User, }), ); - let outcome = self.build_skill_outcome(roots, &config_layer_stack); + let skill_config_rules = skill_config_rules_from_stack(&config_layer_stack); + let outcome = self.build_skill_outcome(roots, &skill_config_rules); let mut cache = self .cache_by_cwd .write() @@ -204,12 +204,14 @@ impl SkillsManager { fn build_skill_outcome( &self, roots: Vec, - config_layer_stack: &crate::config_loader::ConfigLayerStack, + skill_config_rules: &SkillConfigRules, ) -> SkillLoadOutcome { - crate::skills::filter_skill_load_outcome_for_product( - finalize_skill_outcome(load_skills_from_roots(roots), config_layer_stack), + let outcome = crate::skills::filter_skill_load_outcome_for_product( + load_skills_from_roots(roots), self.restriction_product, - ) + ); + let disabled_paths = resolve_disabled_skill_paths(&outcome.skills, skill_config_rules); + finalize_skill_outcome(outcome, disabled_paths) } pub fn clear_cache(&self) { @@ -256,7 +258,7 @@ impl SkillsManager { #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ConfigSkillsCacheKey { roots: Vec<(PathBuf, u8)>, - disabled_paths: Vec, + skill_config_rules: SkillConfigRules, } pub(crate) fn bundled_skills_enabled_from_stack( @@ -281,53 +283,10 @@ pub(crate) fn bundled_skills_enabled_from_stack( skills.bundled.unwrap_or_default().enabled } -fn disabled_paths_from_stack( - config_layer_stack: &crate::config_loader::ConfigLayerStack, -) -> HashSet { - let mut configs = HashMap::new(); - for layer in config_layer_stack.get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ true, - ) { - if !matches!( - layer.name, - ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags - ) { - continue; - } - - let Some(skills_value) = layer.config.get("skills") else { - continue; - }; - let skills: SkillsConfig = match skills_value.clone().try_into() { - Ok(skills) => skills, - Err(err) => { - warn!("invalid skills config: {err}"); - continue; - } - }; - - for entry in skills.config { - let path = normalize_override_path(entry.path.as_path()); - configs.insert(path, entry.enabled); - } - } - - configs - .into_iter() - .filter_map(|(path, enabled)| (!enabled).then_some(path)) - .collect() -} - fn config_skills_cache_key( roots: &[SkillRoot], - config_layer_stack: &crate::config_loader::ConfigLayerStack, + skill_config_rules: &SkillConfigRules, ) -> ConfigSkillsCacheKey { - let mut disabled_paths: Vec = disabled_paths_from_stack(config_layer_stack) - .into_iter() - .collect(); - disabled_paths.sort_unstable(); - ConfigSkillsCacheKey { roots: roots .iter() @@ -341,15 +300,15 @@ fn config_skills_cache_key( (root.path.clone(), scope_rank) }) .collect(), - disabled_paths, + skill_config_rules: skill_config_rules.clone(), } } fn finalize_skill_outcome( mut outcome: SkillLoadOutcome, - config_layer_stack: &crate::config_loader::ConfigLayerStack, + disabled_paths: HashSet, ) -> SkillLoadOutcome { - outcome.disabled_paths = disabled_paths_from_stack(config_layer_stack); + outcome.disabled_paths = disabled_paths; let (by_scripts_dir, by_doc_path) = build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation()); outcome.implicit_skills_by_scripts_dir = Arc::new(by_scripts_dir); @@ -357,10 +316,6 @@ fn finalize_skill_outcome( outcome } -fn normalize_override_path(path: &Path) -> PathBuf { - dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) -} - fn normalize_extra_user_roots(extra_user_roots: &[PathBuf]) -> Vec { let mut normalized: Vec = extra_user_roots .iter() diff --git a/codex-rs/core/src/skills/manager_tests.rs b/codex-rs/core/src/skills/manager_tests.rs index cb3c48ed7f..d9e5859f4e 100644 --- a/codex-rs/core/src/skills/manager_tests.rs +++ b/codex-rs/core/src/skills/manager_tests.rs @@ -5,6 +5,10 @@ use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirementsToml; use crate::plugins::PluginsManager; +use crate::skills::SkillMetadata; +use crate::skills::config_rules::resolve_disabled_skill_paths; +use crate::skills::config_rules::skill_config_rules_from_stack; +use codex_app_server_protocol::ConfigLayerSource; use pretty_assertions::assert_eq; use std::fs; use std::path::PathBuf; @@ -17,6 +21,49 @@ fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &s fs::write(skill_dir.join("SKILL.md"), content).unwrap(); } +fn write_plugin_skill( + codex_home: &TempDir, + marketplace: &str, + plugin_name: &str, + dir: &str, + name: &str, + description: &str, +) -> PathBuf { + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join(marketplace) + .join(plugin_name) + .join("local"); + let skill_dir = plugin_root.join("skills").join(dir); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + ) + .unwrap(); + let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); + let skill_path = skill_dir.join("SKILL.md"); + fs::write(&skill_path, content).unwrap(); + skill_path +} + +fn test_skill(name: &str, path: PathBuf) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: "test".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + managed_network_override: None, + path_to_skills_md: path, + scope: SkillScope::User, + } +} + #[test] fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -68,6 +115,68 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() { assert_eq!(outcome2.skills, outcome1.skills); } +#[tokio::test] +async fn skills_for_config_disables_plugin_skills_by_name() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let skill_path = write_plugin_skill( + &codex_home, + "test", + "sample", + "sample-search", + "sample-search", + "search sample data", + ); + fs::write( + codex_home.path().join(crate::config::CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[[skills.config]] +name = "sample:sample-search" +enabled = false + +[plugins."sample@test"] +enabled = true +"#, + ) + .expect("write config"); + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }) + .build() + .await + .expect("load config"); + + let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); + let skills_manager = SkillsManager::new( + codex_home.path().to_path_buf(), + plugins_manager, + config.bundled_skills_enabled(), + ); + + let outcome = skills_manager.skills_for_config(&config); + let skill = outcome + .skills + .iter() + .find(|skill| skill.name == "sample:sample-search") + .expect("plugin skill should load"); + let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize"); + + assert_eq!(skill.path_to_skills_md, skill_path); + assert!(outcome.disabled_paths.contains(&skill.path_to_skills_md)); + assert!( + !outcome + .allowed_skills_for_implicit_invocation() + .iter() + .any(|allowed_skill| allowed_skill.path_to_skills_md == skill.path_to_skills_md) + ); +} + #[tokio::test] async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -282,9 +391,10 @@ fn normalize_extra_user_roots_is_stable_for_equivalent_inputs() { #[cfg_attr(windows, ignore)] #[test] -fn disabled_paths_from_stack_allows_session_flags_to_override_user_layer() { +fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() { let tempdir = tempfile::tempdir().expect("tempdir"); let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let skill = test_skill("demo-skill", skill_path.clone()); let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( @@ -316,14 +426,19 @@ enabled = true ) .expect("valid config layer stack"); - assert_eq!(disabled_paths_from_stack(&stack), HashSet::new()); + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::new() + ); } #[cfg_attr(windows, ignore)] #[test] -fn disabled_paths_from_stack_allows_session_flags_to_disable_user_enabled_skill() { +fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill() { let tempdir = tempfile::tempdir().expect("tempdir"); let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let skill = test_skill("demo-skill", skill_path.clone()); let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( @@ -355,12 +470,88 @@ enabled = false ) .expect("valid config layer stack"); + let skill_config_rules = skill_config_rules_from_stack(&stack); assert_eq!( - disabled_paths_from_stack(&stack), + resolve_disabled_skill_paths(&[skill], &skill_config_rules), HashSet::from([skill_path]) ); } +#[cfg_attr(windows, ignore)] +#[test] +fn disabled_paths_for_skills_disables_matching_name_selectors() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let skill = test_skill("github:yeet", skill_path.clone()); + let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) + .expect("user config path should be absolute"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + toml::from_str( + r#"[[skills.config]] +name = "github:yeet" +enabled = false +"#, + ) + .expect("user layer toml"), + ); + let stack = ConfigLayerStack::new( + vec![user_layer], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::from([skill_path]) + ); +} + +#[cfg_attr(windows, ignore)] +#[test] +fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let skill = test_skill("github:yeet", skill_path.clone()); + let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) + .expect("user config path should be absolute"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + toml::from_str(&format!( + r#"[[skills.config]] +path = "{}" +enabled = false +"#, + skill_path.display() + )) + .expect("user layer toml"), + ); + let session_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str( + r#"[[skills.config]] +name = "github:yeet" +enabled = true +"#, + ) + .expect("session layer toml"), + ); + let stack = ConfigLayerStack::new( + vec![user_layer, session_layer], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::new() + ); +} + #[cfg_attr(windows, ignore)] #[tokio::test] async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() { diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index 4138ecbb86..ad76cd9da0 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod config_rules; mod env_var_dependencies; pub mod injection; pub(crate) mod invocation_utils; From 7b92a90612b6c2e1075a0f4087ff924f8ca74b58 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 23 Mar 2026 13:47:33 -0700 Subject: [PATCH 23/38] Unify realtime stop handling in TUI (#15529) ## Summary - route /realtime, Ctrl+C, and deleted realtime meters through the same realtime stop path - keep generic transcription placeholder cleanup free of realtime shutdown side effects ## Testing - Ran - Relied on CI for verification; did not run local tests --------- Co-authored-by: Codex --- codex-rs/tui/src/app.rs | 6 +++++- codex-rs/tui/src/chatwidget.rs | 11 ++--------- codex-rs/tui/src/chatwidget/realtime.rs | 17 +++++++++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 4 ++-- codex-rs/tui_app_server/src/app.rs | 6 +++++- codex-rs/tui_app_server/src/chatwidget.rs | 11 ++--------- .../tui_app_server/src/chatwidget/realtime.rs | 17 +++++++++++++++++ codex-rs/tui_app_server/src/chatwidget/tests.rs | 4 ++-- 8 files changed, 52 insertions(+), 24 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4860787e80..0d77e3c7f8 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4174,7 +4174,11 @@ impl App { AppEvent::UpdateRecordingMeter { id, text } => { // Update in place to preserve the element id for subsequent frames. let updated = self.chat_widget.update_transcription_in_place(&id, &text); - if updated { + if updated + || self + .chat_widget + .stop_realtime_conversation_for_deleted_meter(&id) + { tui.frame_requester().schedule_frame(); } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3f548fc23d..b6ccf71189 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4569,7 +4569,7 @@ impl ChatWidget { return; } if self.realtime_conversation.is_live() { - self.request_realtime_conversation_close(/*info_message*/ None); + self.stop_realtime_conversation_from_ui(); } else { self.start_realtime_conversation(); } @@ -8718,7 +8718,7 @@ impl ChatWidget { self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; - self.request_realtime_conversation_close(/*info_message*/ None); + self.stop_realtime_conversation_from_ui(); return; } let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); @@ -9394,13 +9394,6 @@ impl ChatWidget { } pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { - #[cfg(not(target_os = "linux"))] - if self.realtime_conversation.is_live() - && self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id) - { - self.realtime_conversation.meter_placeholder_id = None; - self.request_realtime_conversation_close(/*info_message*/ None); - } self.bottom_pane.remove_transcription_placeholder(id); // Ensure the UI redraws to reflect placeholder removal. self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 2e4ab70e70..df51a72317 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -106,6 +106,23 @@ pub(super) struct PendingSteerCompareKey { } impl ChatWidget { + pub(super) fn stop_realtime_conversation_from_ui(&mut self) { + self.request_realtime_conversation_close(/*info_message*/ None); + } + + #[cfg(not(target_os = "linux"))] + pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool { + if self.realtime_conversation.is_live() + && self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id) + { + self.realtime_conversation.meter_placeholder_id = None; + self.stop_realtime_conversation_from_ui(); + return true; + } + + false + } + pub(super) fn rendered_user_message_event_from_parts( message: String, text_elements: Vec, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 8e9ccb0404..8060505699 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -5208,13 +5208,13 @@ async fn realtime_error_closes_without_followup_closed_info() { #[cfg(not(target_os = "linux"))] #[tokio::test] -async fn removing_active_realtime_placeholder_closes_realtime_conversation() { +async fn deleted_realtime_meter_uses_shared_stop_path() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.realtime_conversation.phase = RealtimeConversationPhase::Active; let placeholder_id = chat.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤"); chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone()); - chat.remove_transcription_placeholder(&placeholder_id); + assert!(chat.stop_realtime_conversation_for_deleted_meter(&placeholder_id)); next_realtime_close_op(&mut op_rx); assert_eq!(chat.realtime_conversation.meter_placeholder_id, None); diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 97c1e549fa..6de3a2a34a 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -4699,7 +4699,11 @@ impl App { AppEvent::UpdateRecordingMeter { id, text } => { // Update in place to preserve the element id for subsequent frames. let updated = self.chat_widget.update_transcription_in_place(&id, &text); - if updated { + if updated + || self + .chat_widget + .stop_realtime_conversation_for_deleted_meter(&id) + { tui.frame_requester().schedule_frame(); } } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 3242046bf1..4796401cc9 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -4725,7 +4725,7 @@ impl ChatWidget { return; } if self.realtime_conversation.is_live() { - self.request_realtime_conversation_close(/*info_message*/ None); + self.stop_realtime_conversation_from_ui(); } else { self.start_realtime_conversation(); } @@ -9952,7 +9952,7 @@ impl ChatWidget { self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; - self.request_realtime_conversation_close(/*info_message*/ None); + self.stop_realtime_conversation_from_ui(); return; } let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); @@ -10565,13 +10565,6 @@ impl ChatWidget { } pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { - #[cfg(not(target_os = "linux"))] - if self.realtime_conversation.is_live() - && self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id) - { - self.realtime_conversation.meter_placeholder_id = None; - self.request_realtime_conversation_close(/*info_message*/ None); - } self.bottom_pane.remove_transcription_placeholder(id); // Ensure the UI redraws to reflect placeholder removal. self.request_redraw(); diff --git a/codex-rs/tui_app_server/src/chatwidget/realtime.rs b/codex-rs/tui_app_server/src/chatwidget/realtime.rs index 0d5363daad..6860a7ca05 100644 --- a/codex-rs/tui_app_server/src/chatwidget/realtime.rs +++ b/codex-rs/tui_app_server/src/chatwidget/realtime.rs @@ -202,6 +202,23 @@ impl ChatWidget { vec![("/realtime".to_string(), "stop live voice".to_string())] } + pub(super) fn stop_realtime_conversation_from_ui(&mut self) { + self.request_realtime_conversation_close(/*info_message*/ None); + } + + #[cfg(not(target_os = "linux"))] + pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool { + if self.realtime_conversation.is_live() + && self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id) + { + self.realtime_conversation.meter_placeholder_id = None; + self.stop_realtime_conversation_from_ui(); + return true; + } + + false + } + pub(super) fn start_realtime_conversation(&mut self) { self.realtime_conversation.phase = RealtimeConversationPhase::Starting; self.realtime_conversation.requested_close = false; diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 2da8ff77a8..97ce07f37f 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -5833,13 +5833,13 @@ async fn realtime_error_closes_without_followup_closed_info() { #[cfg(not(target_os = "linux"))] #[tokio::test] -async fn removing_active_realtime_placeholder_closes_realtime_conversation() { +async fn deleted_realtime_meter_uses_shared_stop_path() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.realtime_conversation.phase = RealtimeConversationPhase::Active; let placeholder_id = chat.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤"); chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone()); - chat.remove_transcription_placeholder(&placeholder_id); + assert!(chat.stop_realtime_conversation_for_deleted_meter(&placeholder_id)); next_realtime_close_op(&mut op_rx); assert_eq!(chat.realtime_conversation.meter_placeholder_id, None); From 7eb9e75b864db7c2e6d8e4b68811ecf4843ea914 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 23 Mar 2026 20:51:07 +0000 Subject: [PATCH 24/38] fix: main tui (#15557) --- codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui_app_server/src/chatwidget/tests.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 8060505699..06eed8c8cc 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -7194,6 +7194,7 @@ fn plugins_test_detail( short_description: None, interface: None, path: PathBuf::from(format!("/skills/{name}/SKILL.md")), + enabled: true, }) .collect(), apps: apps diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 97ce07f37f..df229f4c85 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -7791,6 +7791,7 @@ fn plugins_test_detail( short_description: None, interface: None, path: PathBuf::from(format!("/skills/{name}/SKILL.md")), + enabled: true, }) .collect(), apps: apps From 18f1a08bc9c6e39331d9cf34ee240ea0124173cb Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 23 Mar 2026 21:09:00 +0000 Subject: [PATCH 25/38] feat: new op type for sub-agents communication (#15556) Add `InterAgentCommunication` for v2 agent communication --- codex-rs/core/src/agent/control.rs | 10 +-- .../core/src/agent/inter_agent_instruction.rs | 74 ----------------- codex-rs/core/src/agent/mod.rs | 1 - codex-rs/core/src/codex.rs | 28 +++++++ codex-rs/core/src/codex_thread.rs | 33 +------- codex-rs/core/src/context_manager/history.rs | 4 +- codex-rs/core/src/tasks/mod.rs | 1 + codex-rs/core/src/thread_manager.rs | 14 ---- .../src/tools/handlers/multi_agents_tests.rs | 23 +++++ .../handlers/multi_agents_v2/send_input.rs | 15 +--- codex-rs/protocol/src/protocol.rs | 83 +++++++++++++++++++ 11 files changed, 145 insertions(+), 141 deletions(-) delete mode 100644 codex-rs/core/src/agent/inter_agent_instruction.rs diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 5bc5a0711c..3bdcc2efd9 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1,6 +1,4 @@ use crate::agent::AgentStatus; -use crate::agent::inter_agent_instruction::InterAgentDelivery; -use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::agent::registry::AgentMetadata; use crate::agent::registry::AgentRegistry; use crate::agent::role::DEFAULT_ROLE_NAME; @@ -23,6 +21,7 @@ use codex_protocol::ThreadId; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; @@ -488,18 +487,17 @@ impl AgentControl { .await } - pub(crate) async fn deliver_inter_agent_instruction( + pub(crate) async fn send_inter_agent_communication( &self, agent_id: ThreadId, - instruction: InterAgentInstruction, - delivery: InterAgentDelivery, + communication: InterAgentCommunication, ) -> CodexResult { let state = self.upgrade()?; self.handle_thread_request_result( agent_id, &state, state - .deliver_inter_agent_instruction(agent_id, instruction, delivery) + .send_op(agent_id, Op::InterAgentCommunication { communication }) .await, ) .await diff --git a/codex-rs/core/src/agent/inter_agent_instruction.rs b/codex-rs/core/src/agent/inter_agent_instruction.rs deleted file mode 100644 index 0eff40beb4..0000000000 --- a/codex-rs/core/src/agent/inter_agent_instruction.rs +++ /dev/null @@ -1,74 +0,0 @@ -use codex_protocol::AgentPath; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseItem; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum InterAgentDelivery { - CurrentTurn, - NextTurn, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct InterAgentInstruction { - author: AgentPath, - recipient: AgentPath, - other_recipients: Vec, - content: String, -} - -impl InterAgentInstruction { - pub(crate) fn new( - author: AgentPath, - recipient: AgentPath, - other_recipients: Vec, - content: String, - ) -> Self { - Self { - author, - recipient, - other_recipients, - content, - } - } - - pub(crate) fn to_response_item(&self) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: self.as_text(), - }], - end_turn: None, - phase: None, - } - } - - pub(crate) fn is_message_content(content: &[ContentItem]) -> bool { - content.iter().any(|content_item| match content_item { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - Self::is_instruction_text(text) - } - _ => false, - }) - } - - fn as_text(&self) -> String { - let other_recipients = self - .other_recipients - .iter() - .map(std::string::ToString::to_string) - .collect::>() - .join(", "); - format!( - "author: {}\nrecipient: {}\nother_recipients: [{other_recipients}]\nContent: {}", - self.author, self.recipient, self.content - ) - } - - fn is_instruction_text(text: &str) -> bool { - text.starts_with("author: ") - && text.contains("\nrecipient: ") - && text.contains("\nother_recipients: [") - && text.contains("]\nContent: ") - } -} diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 3f14e5c590..350962dc08 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,6 +1,5 @@ pub(crate) mod agent_resolver; pub(crate) mod control; -pub(crate) mod inter_agent_instruction; mod registry; pub(crate) mod role; pub(crate) mod status; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b040883c25..9b81025526 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4306,6 +4306,10 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } + Op::InterAgentCommunication { communication } => { + handlers::inter_agent_communication(&sess, sub.id.clone(), communication).await; + false + } Op::ExecApproval { id: approval_id, turn_id, @@ -4485,6 +4489,7 @@ mod handlers { use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::ListCustomPromptsResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; @@ -4627,6 +4632,29 @@ mod handlers { } } + pub async fn inter_agent_communication( + sess: &Arc, + sub_id: String, + communication: InterAgentCommunication, + ) { + let pending_item = communication.to_response_input_item(); + if sess + .inject_response_items(vec![pending_item.clone()]) + .await + .is_ok() + { + return; + } + + let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; + sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + .await; + sess.queue_response_items_for_next_turn(vec![pending_item]) + .await; + sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new()) + .await; + } + pub async fn run_user_shell_command(sess: &Arc, sub_id: String, command: String) { if let Some((turn_context, cancellation_token)) = sess.active_turn_context_and_cancellation_token().await diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index ff36de6c15..9fa7069118 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,6 +1,4 @@ use crate::agent::AgentStatus; -use crate::agent::inter_agent_instruction::InterAgentDelivery; -use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; @@ -158,6 +156,7 @@ impl CodexThread { /// If the thread already has an active turn, the message is queued as pending input for that /// turn. Otherwise it is queued at session scope and a regular turn is started so the agent /// can consume that pending input through the normal turn pipeline. + #[cfg(test)] pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult { let submission_id = uuid::Uuid::new_v4().to_string(); let pending_item = pending_message_input_item(&message)?; @@ -180,36 +179,6 @@ impl CodexThread { Ok(submission_id) } - pub(crate) async fn deliver_inter_agent_instruction( - &self, - instruction: InterAgentInstruction, - delivery: InterAgentDelivery, - ) -> CodexResult { - let message = instruction.to_response_item(); - match delivery { - InterAgentDelivery::CurrentTurn => self.append_message(message).await, - InterAgentDelivery::NextTurn => self.queue_message_for_next_turn(message).await, - } - } - - /// Queue a prebuilt message so the next turn records it before any submitted user input. - pub(crate) async fn queue_message_for_next_turn( - &self, - message: ResponseItem, - ) -> CodexResult { - let submission_id = uuid::Uuid::new_v4().to_string(); - let pending_item = pending_message_input_item(&message)?; - self.codex - .session - .queue_response_items_for_next_turn(vec![pending_item]) - .await; - self.codex - .session - .ensure_task_for_queued_response_items() - .await; - Ok(submission_id) - } - pub fn rollout_path(&self) -> Option { self.rollout_path.clone() } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 6ed0370481..57807e5f85 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,4 +1,3 @@ -use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::codex::TurnContext; use crate::context_manager::normalize; use crate::event_mapping::is_contextual_user_message_content; @@ -18,6 +17,7 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::InputModality; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnContextItem; @@ -638,7 +638,7 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { } fn is_inter_agent_instruction_content(content: &[ContentItem]) -> bool { - InterAgentInstruction::is_message_content(content) + InterAgentCommunication::is_message_content(content) } fn user_message_positions(items: &[ResponseItem]) -> Vec { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b2f110486c..d9c4954cc8 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -405,6 +405,7 @@ impl Session { turn.add_task(task); *active = Some(turn); } + async fn take_active_turn(&self) -> Option { let mut active = self.active_turn.lock().await; active.take() diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index d93bf37187..185a32a70f 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -3,8 +3,6 @@ use crate::CodexAuth; use crate::ModelProviderInfo; use crate::OPENAI_PROVIDER_ID; use crate::agent::AgentControl; -use crate::agent::inter_agent_instruction::InterAgentDelivery; -use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::codex::Codex; use crate::codex::CodexSpawnArgs; use crate::codex::CodexSpawnOk; @@ -621,18 +619,6 @@ impl ThreadManagerState { thread.append_message(message).await } - pub(crate) async fn deliver_inter_agent_instruction( - &self, - thread_id: ThreadId, - instruction: InterAgentInstruction, - delivery: InterAgentDelivery, - ) -> CodexResult { - let thread = self.get_thread(thread_id).await?; - thread - .deliver_inter_agent_instruction(instruction, delivery) - .await - } - /// Remove a thread from the manager by ID, returning it when present. pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option> { self.threads.write().await.remove(thread_id) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index f60137f7fe..7b8471f03e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -24,6 +24,7 @@ use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHand use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; use crate::turn_diff_tracker::TurnDiffTracker; use codex_features::Feature; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; @@ -373,6 +374,18 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( .await .expect("send_input should accept v2 path"); + assert!(manager.captured_ops().iter().any(|(id, op)| { + *id == child_thread_id + && matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == AgentPath::root() + && communication.recipient.as_str() == "/root/test_process" + && communication.other_recipients.is_empty() + && communication.content == "continue" + ) + })); + let child_thread = manager .get_thread(child_thread_id) .await @@ -618,6 +631,16 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( .filter_map(|(id, op)| (*id == agent_id).then_some(op)) .collect(); assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt))); + assert!(ops_for_agent.iter().any(|op| { + matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == AgentPath::root() + && communication.recipient.as_str() == "/root/worker" + && communication.other_recipients.is_empty() + && communication.content == "continue" + ) + })); assert!(!ops_for_agent.iter().any(|op| matches!( op, Op::UserInput { items, .. } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs index 8c29a7aec8..c17e12cca8 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs @@ -1,6 +1,5 @@ use super::*; -use crate::agent::inter_agent_instruction::InterAgentDelivery; -use crate::agent::inter_agent_instruction::InterAgentInstruction; +use codex_protocol::protocol::InterAgentCommunication; pub(crate) struct Handler; @@ -66,7 +65,7 @@ impl ToolHandler for Handler { "target agent is missing an agent_path".to_string(), ) })?; - let instruction = InterAgentInstruction::new( + let communication = InterAgentCommunication::new( turn.session_source .get_agent_path() .unwrap_or_else(AgentPath::root), @@ -77,15 +76,7 @@ impl ToolHandler for Handler { session .services .agent_control - .deliver_inter_agent_instruction( - receiver_thread_id, - instruction, - if args.interrupt { - InterAgentDelivery::NextTurn - } else { - InterAgentDelivery::CurrentTurn - }, - ) + .send_inter_agent_communication(receiver_thread_id, communication) .await } else { session diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c88c4ecee8..c8af844f7b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -38,6 +38,7 @@ use crate::message_history::HistoryEntry; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::MessagePhase; +use crate::models::ResponseInputItem; use crate::models::ResponseItem; use crate::models::WebSearchAction; use crate::num_format::format_with_separators; @@ -293,6 +294,12 @@ pub enum Op { personality: Option, }, + /// Inter-agent communication that should be recorded as assistant history + /// while still using the normal thread submission lifecycle. + InterAgentCommunication { + communication: InterAgentCommunication, + }, + /// Override parts of the persistent turn context for subsequent turns. /// /// All fields are optional; when omitted, the existing value is preserved. @@ -499,6 +506,81 @@ pub enum Op { ListModels, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] +pub struct InterAgentCommunication { + pub author: AgentPath, + pub recipient: AgentPath, + #[serde(default)] + pub other_recipients: Vec, + pub content: String, +} + +impl InterAgentCommunication { + pub fn new( + author: AgentPath, + recipient: AgentPath, + other_recipients: Vec, + content: String, + ) -> Self { + Self { + author, + recipient, + other_recipients, + content, + } + } + + pub fn to_response_item(&self) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: self.as_text(), + }], + end_turn: None, + phase: None, + } + } + + pub fn to_response_input_item(&self) -> ResponseInputItem { + ResponseInputItem::Message { + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: self.as_text(), + }], + } + } + + pub fn is_message_content(content: &[ContentItem]) -> bool { + content.iter().any(|content_item| match content_item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + Self::is_instruction_text(text) + } + _ => false, + }) + } + + fn as_text(&self) -> String { + let other_recipients = self + .other_recipients + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(", "); + format!( + "author: {}\nrecipient: {}\nother_recipients: [{other_recipients}]\nContent: {}", + self.author, self.recipient, self.content + ) + } + + fn is_instruction_text(text: &str) -> bool { + text.starts_with("author: ") + && text.contains("\nrecipient: ") + && text.contains("\nother_recipients: [") + && text.contains("]\nContent: ") + } +} + impl Op { pub fn kind(&self) -> &'static str { match self { @@ -510,6 +592,7 @@ impl Op { Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", Self::UserTurn { .. } => "user_turn", + Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", Self::ExecApproval { .. } => "exec_approval", Self::PatchApproval { .. } => "patch_approval", From 73bbb07ba8302932a5462811bc68da0ef66ce50a Mon Sep 17 00:00:00 2001 From: Andrei Eternal Date: Mon, 23 Mar 2026 14:32:59 -0700 Subject: [PATCH 26/38] [hooks] add non-streaming (non-stdin style) shell-only PreToolUse support (#15211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add `PreToolUse` hook for bash-like tool execution only at first - block shell execution before dispatch with deny-only hook behavior - introduces common.rs matcher framework for matching when hooks are run example run: ``` › run three parallel echo commands, and the second one should echo "[block-pre-tool-use]" as a test • Running the three echo commands in parallel now and I’ll report the output directly. • Running PreToolUse hook: name for demo pre tool use hook • Running PreToolUse hook: name for demo pre tool use hook • Running PreToolUse hook: name for demo pre tool use hook PreToolUse hook (completed) warning: wizard-tower PreToolUse demo inspected Bash: echo "first parallel echo" PreToolUse hook (blocked) warning: wizard-tower PreToolUse demo blocked a Bash command on purpose. feedback: PreToolUse demo blocked the command. Remove [block-pre-tool-use] to continue. PreToolUse hook (completed) warning: wizard-tower PreToolUse demo inspected Bash: echo "third parallel echo" • Ran echo "first parallel echo" └ first parallel echo • Ran echo "third parallel echo" └ third parallel echo • Three little waves went out in parallel. 1. printed first parallel echo 2. was blocked before execution because it contained the exact test string [block-pre-tool-use] 3. printed third parallel echo There was also an unrelated macOS defaults warning around the successful commands, but the echoes themselves worked fine. If you want, I can rerun the second one with a slightly modified string so it passes cleanly. ``` --- .../schema/json/ServerNotification.json | 1 + .../codex_app_server_protocol.schemas.json | 1 + .../codex_app_server_protocol.v2.schemas.json | 1 + .../json/v2/HookCompletedNotification.json | 1 + .../json/v2/HookStartedNotification.json | 1 + .../schema/typescript/v2/HookEventName.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 2 +- codex-rs/core/src/hook_runtime.rs | 32 ++ codex-rs/core/src/tools/registry.rs | 47 ++ codex-rs/core/src/tools/registry_tests.rs | 62 +++ codex-rs/core/tests/suite/hooks.rs | 426 ++++++++++++++++ codex-rs/core/tests/suite/otel.rs | 3 +- .../src/event_processor_with_human_output.rs | 1 + .../pre-tool-use.command.input.schema.json | 80 +++ .../pre-tool-use.command.output.schema.json | 101 ++++ .../session-start.command.output.schema.json | 1 + ...r-prompt-submit.command.output.schema.json | 1 + codex-rs/hooks/src/engine/config.rs | 2 + codex-rs/hooks/src/engine/discovery.rs | 103 +++- codex-rs/hooks/src/engine/dispatcher.rs | 59 ++- codex-rs/hooks/src/engine/mod.rs | 11 + codex-rs/hooks/src/engine/output_parser.rs | 152 ++++++ codex-rs/hooks/src/engine/schema_loader.rs | 12 + codex-rs/hooks/src/events/common.rs | 111 ++++ codex-rs/hooks/src/events/mod.rs | 3 +- codex-rs/hooks/src/events/pre_tool_use.rs | 479 ++++++++++++++++++ codex-rs/hooks/src/lib.rs | 2 + codex-rs/hooks/src/registry.rs | 13 + codex-rs/hooks/src/schema.rs | 112 +++- codex-rs/protocol/src/protocol.rs | 1 + codex-rs/tui/src/chatwidget.rs | 1 + ...tool_use_hook_events_render_snapshot.snap} | 5 +- ...on_start_hook_events_render_snapshot.snap} | 1 - codex-rs/tui/src/chatwidget/tests.rs | 46 +- codex-rs/tui_app_server/src/chatwidget.rs | 1 + ..._tool_use_hook_events_render_snapshot.snap | 9 + ...on_start_hook_events_render_snapshot.snap} | 0 .../tui_app_server/src/chatwidget/tests.rs | 46 +- 38 files changed, 1877 insertions(+), 55 deletions(-) create mode 100644 codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json create mode 100644 codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json create mode 100644 codex-rs/hooks/src/events/pre_tool_use.rs rename codex-rs/{tui_app_server/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap => tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap} (59%) rename codex-rs/tui/src/chatwidget/snapshots/{codex_tui__chatwidget__tests__hook_events_render_snapshot.snap => codex_tui__chatwidget__tests__session_start_hook_events_render_snapshot.snap} (91%) create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap rename codex-rs/tui_app_server/src/chatwidget/snapshots/{codex_tui_app_server__chatwidget__tests__hook_events_render_snapshot.snap => codex_tui_app_server__chatwidget__tests__session_start_hook_events_render_snapshot.snap} (100%) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 7d192f0b01..b576581cf9 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1180,6 +1180,7 @@ }, "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" 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 6b731a9b50..a644549325 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 @@ -7994,6 +7994,7 @@ }, "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0d69834f15..71e6034529 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4698,6 +4698,7 @@ }, "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index 84fea949c8..881c343601 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -3,6 +3,7 @@ "definitions": { "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 7b55420da2..18fdb5008d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -3,6 +3,7 @@ "definitions": { "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index a531b78dcf..b75ee3930a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.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 HookEventName = "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e270d8ad61..3e30f81323 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -377,7 +377,7 @@ v2_enum_from_core!( v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - SessionStart, UserPromptSubmit, Stop + PreToolUse, SessionStart, UserPromptSubmit, Stop } ); diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 26b49facc3..7e6ecaca10 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -1,6 +1,8 @@ use std::future::Future; use std::sync::Arc; +use codex_hooks::PreToolUseOutcome; +use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; @@ -109,6 +111,36 @@ pub(crate) async fn run_pending_session_start_hooks( .await } +pub(crate) async fn run_pre_tool_use_hooks( + sess: &Arc, + turn_context: &Arc, + tool_use_id: String, + command: String, +) -> Option { + let request = PreToolUseRequest { + session_id: sess.conversation_id, + turn_id: turn_context.sub_id.clone(), + cwd: turn_context.cwd.clone(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + tool_name: "Bash".to_string(), + tool_use_id, + command, + }; + let preview_runs = sess.hooks().preview_pre_tool_use(&request); + emit_hook_started_events(sess, turn_context, preview_runs).await; + + let PreToolUseOutcome { + hook_events, + should_block, + block_reason, + } = sess.hooks().run_pre_tool_use(request).await; + emit_hook_completed_events(sess, turn_context, hook_events).await; + + if should_block { block_reason } else { None } +} + pub(crate) async fn run_user_prompt_submit_hooks( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index bcee62a044..37b7d015b3 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -5,6 +5,7 @@ use std::time::Instant; use crate::client_common::tools::ToolSpec; use crate::function_tool::FunctionCallError; +use crate::hook_runtime::run_pre_tool_use_hooks; use crate::memories::usage::emit_metric_for_tool_read; use crate::protocol::SandboxPolicy; use crate::sandbox_tags::sandbox_tag; @@ -20,7 +21,10 @@ use codex_hooks::HookToolInput; use codex_hooks::HookToolInputLocalShell; use codex_hooks::HookToolKind; use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ShellCommandToolCallParams; +use codex_protocol::models::ShellToolCallParams; use codex_utils_readiness::Readiness; +use serde::Deserialize; use tracing::warn; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -243,6 +247,20 @@ impl ToolRegistry { return Err(FunctionCallError::Fatal(message)); } + if let Some(command) = pre_tool_use_command(tool_name.as_ref(), &invocation.payload) + && let Some(reason) = run_pre_tool_use_hooks( + &invocation.session, + &invocation.turn, + invocation.call_id.clone(), + command.clone(), + ) + .await + { + return Err(FunctionCallError::RespondToModel(format!( + "Bash command blocked by hook: {reason}. Command: {command}" + ))); + } + let is_mutating = handler.is_mutating(&invocation).await; let response_cell = tokio::sync::Mutex::new(None); let invocation_for_tool = invocation.clone(); @@ -413,6 +431,35 @@ fn sandbox_policy_tag(policy: &SandboxPolicy) -> &'static str { } } +#[derive(Deserialize)] +struct PreToolUseExecCommandArgs { + cmd: String, +} + +fn pre_tool_use_command(tool_name: &str, payload: &ToolPayload) -> Option { + match (tool_name, payload) { + ("shell" | "container.exec", ToolPayload::Function { arguments }) => { + serde_json::from_str::(arguments) + .ok() + .map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command)) + } + ("local_shell", ToolPayload::LocalShell { params }) => Some( + codex_shell_command::parse_command::shlex_join(¶ms.command), + ), + ("shell_command", ToolPayload::Function { arguments }) => { + serde_json::from_str::(arguments) + .ok() + .map(|params| params.command) + } + ("exec_command", ToolPayload::Function { arguments }) => { + serde_json::from_str::(arguments) + .ok() + .map(|params| params.cmd) + } + _ => None, + } +} + // Hooks use a separate wire-facing input type so hook payload JSON stays stable // and decoupled from core's internal tool runtime representation. impl From<&ToolPayload> for HookToolInput { diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index 5d9e98df35..46e7d0c3f6 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; use async_trait::async_trait; +use codex_protocol::models::ShellToolCallParams; use pretty_assertions::assert_eq; struct TestHandler; @@ -48,3 +50,63 @@ fn handler_looks_up_namespaced_aliases_explicitly() { .is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler)) ); } + +#[test] +fn pre_tool_use_command_uses_raw_shell_command_input() { + let payload = ToolPayload::Function { + arguments: serde_json::json!({ "command": "printf shell command" }).to_string(), + }; + + assert_eq!( + pre_tool_use_command("shell_command", &payload), + Some("printf shell command".to_string()) + ); +} + +#[test] +fn pre_tool_use_command_shell_joins_vector_input() { + let payload = ToolPayload::LocalShell { + params: ShellToolCallParams { + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "printf hi".to_string(), + ], + workdir: None, + timeout_ms: None, + sandbox_permissions: None, + prefix_rule: None, + additional_permissions: None, + justification: None, + }, + }; + + assert_eq!( + pre_tool_use_command("local_shell", &payload), + Some("bash -lc 'printf hi'".to_string()) + ); +} + +#[test] +fn pre_tool_use_command_uses_raw_exec_command_input() { + let payload = ToolPayload::Function { + arguments: serde_json::json!({ "cmd": "printf exec command" }).to_string(), + }; + + assert_eq!( + pre_tool_use_command("exec_command", &payload), + Some("printf exec command".to_string()) + ); +} + +#[test] +fn pre_tool_use_command_skips_non_shell_tools() { + let payload = ToolPayload::Function { + arguments: serde_json::json!({ + "plan": [{ "step": "watch the tide", "status": "pending" }] + }) + .to_string(), + }; + + assert_eq!(pre_tool_use_command("update_plan", &payload), None); +} diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index c7042dd251..c7de5dcb26 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -174,6 +174,69 @@ if payload.get("prompt") == {blocked_prompt_json}: Ok(()) } +fn write_pre_tool_use_hook( + home: &Path, + matcher: Option<&str>, + mode: &str, + reason: &str, +) -> Result<()> { + let script_path = home.join("pre_tool_use_hook.py"); + let log_path = home.join("pre_tool_use_hook_log.jsonl"); + let mode_json = serde_json::to_string(mode).context("serialize pre tool use mode")?; + let reason_json = serde_json::to_string(reason).context("serialize pre tool use reason")?; + let script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{log_path}") +mode = {mode_json} +reason = {reason_json} + +payload = json.load(sys.stdin) + +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +if mode == "json_deny": + print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason + }} + }})) +elif mode == "exit_2": + sys.stderr.write(reason + "\n") + raise SystemExit(2) +"#, + log_path = log_path.display(), + mode_json = mode_json, + reason_json = reason_json, + ); + + let mut group = serde_json::json!({ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running pre tool use hook", + }] + }); + if let Some(matcher) = matcher { + group["matcher"] = Value::String(matcher.to_string()); + } + + let hooks = serde_json::json!({ + "hooks": { + "PreToolUse": [group] + } + }); + + fs::write(&script_path, script).context("write pre tool use hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn write_session_start_hook_recording_transcript(home: &Path) -> Result<()> { let script_path = home.join("session_start_hook.py"); let log_path = home.join("session_start_hook_log.jsonl"); @@ -253,6 +316,15 @@ fn read_stop_hook_inputs(home: &Path) -> Result> { .collect() } +fn read_pre_tool_use_hook_inputs(home: &Path) -> Result> { + fs::read_to_string(home.join("pre_tool_use_hook_log.jsonl")) + .context("read pre tool use hook log")? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("parse pre tool use hook log line")) + .collect() +} + fn read_session_start_hook_inputs(home: &Path) -> Result> { fs::read_to_string(home.join("session_start_hook_log.jsonl")) .context("read session start hook log")? @@ -849,3 +921,357 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu server.shutdown().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-shell-command"; + let marker = std::env::temp_dir().join("pretooluse-shell-command-marker"); + let command = format!("printf blocked > {}", marker.display()); + let args = serde_json::json!({ "command": command }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "shell_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "hook blocked it"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked by pre hook") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover pre tool use marker")?; + } + + test.submit_turn_with_policy( + "run the blocked shell command", + codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("shell command output string"); + assert!( + output.contains("Bash command blocked by hook: blocked by pre hook"), + "blocked tool output should surface the hook reason", + ); + assert!( + output.contains(&format!("Command: {command}")), + "blocked tool output should surface the blocked command", + ); + assert!( + !marker.exists(), + "blocked command should not create marker file" + ); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse"); + assert_eq!(hook_inputs[0]["tool_name"], "Bash"); + assert_eq!(hook_inputs[0]["tool_use_id"], call_id); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + let transcript_path = hook_inputs[0]["transcript_path"] + .as_str() + .expect("pre tool use hook transcript_path"); + assert!( + !transcript_path.is_empty(), + "pre tool use hook should receive a non-empty transcript_path", + ); + assert!( + Path::new(transcript_path).exists(), + "pre tool use hook transcript_path should be materialized on disk", + ); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-local-shell"; + let marker = std::env::temp_dir().join("pretooluse-local-shell-marker"); + let command = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + format!("printf blocked > {}", marker.display()), + ]; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_local_shell_call( + call_id, + "completed", + command.iter().map(String::as_str).collect(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "local shell blocked"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked local shell") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover local shell marker")?; + } + + test.submit_turn("run the blocked local shell command") + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("local shell output string"); + assert!( + output.contains("Bash command blocked by hook: blocked local shell"), + "blocked local shell output should surface the hook reason", + ); + assert!( + output.contains(&format!( + "Command: {}", + codex_shell_command::parse_command::shlex_join(&command) + )), + "blocked local shell output should surface the blocked command", + ); + assert!( + !marker.exists(), + "blocked local shell command should not execute" + ); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!( + hook_inputs[0]["tool_input"]["command"], + codex_shell_command::parse_command::shlex_join(&command), + ); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-exec-command"; + let marker = std::env::temp_dir().join("pretooluse-exec-command-marker"); + let command = format!("printf blocked > {}", marker.display()); + let args = serde_json::json!({ "cmd": command }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "exec_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "exec command blocked"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "exit_2", "blocked exec command") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover exec marker")?; + } + + test.submit_turn("run the blocked exec command").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("exec command output string"); + assert!( + output.contains("Bash command blocked by hook: blocked exec command"), + "blocked exec command output should surface the hook reason", + ); + assert!( + output.contains(&format!("Command: {command}")), + "blocked exec command output should surface the blocked command", + ); + assert!(!marker.exists(), "blocked exec command should not execute"); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["tool_use_id"], call_id); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_does_not_fire_for_non_shell_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-update-plan"; + let args = serde_json::json!({ + "plan": [{ + "step": "watch the tide", + "status": "pending", + }] + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "update_plan", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "plan updated"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_pre_tool_use_hook(home, None, "json_deny", "should not fire") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn("update the plan").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("update plan output string"); + assert!( + !output.contains("should not fire"), + "non-shell tool output should not be blocked by PreToolUse", + ); + + let hook_log_path = test.codex_home_path().join("pre_tool_use_hook_log.jsonl"); + assert!( + !hook_log_path.exists(), + "non-shell tools should not trigger pre tool use hooks", + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index ecf6283664..96df3fd2f1 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -973,6 +973,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { .features .disable(Feature::GhostCommit) .expect("test config should allow feature update"); + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); }) .build(&server) .await @@ -989,7 +990,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { .await .unwrap(); - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; logs_assert(|lines: &[&str]| { let line = lines 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 0e49166b81..2b935fce95 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -988,6 +988,7 @@ impl EventProcessorWithHumanOutput { fn hook_event_name(event_name: HookEventName) -> &'static str { match event_name { + HookEventName::PreToolUse => "PreToolUse", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::Stop => "Stop", diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json new file mode 100644 index 0000000000..86dfac165c --- /dev/null +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + }, + "PreToolUseToolInput": { + "additionalProperties": false, + "properties": { + "command": { + "type": "string" + } + }, + "required": [ + "command" + ], + "type": "object" + } + }, + "properties": { + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "PreToolUse", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "tool_input": { + "$ref": "#/definitions/PreToolUseToolInput" + }, + "tool_name": { + "const": "Bash", + "type": "string" + }, + "tool_use_id": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "tool_input", + "tool_name", + "tool_use_id", + "transcript_path", + "turn_id" + ], + "title": "pre-tool-use.command.input", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json new file mode 100644 index 0000000000..0992983fe4 --- /dev/null +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "HookEventNameWire": { + "enum": [ + "PreToolUse", + "SessionStart", + "UserPromptSubmit", + "Stop" + ], + "type": "string" + }, + "PreToolUseDecisionWire": { + "enum": [ + "approve", + "block" + ], + "type": "string" + }, + "PreToolUseHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "additionalContext": { + "default": null, + "type": "string" + }, + "hookEventName": { + "$ref": "#/definitions/HookEventNameWire" + }, + "permissionDecision": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUsePermissionDecisionWire" + } + ], + "default": null + }, + "permissionDecisionReason": { + "default": null, + "type": "string" + }, + "updatedInput": { + "default": null + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + }, + "PreToolUsePermissionDecisionWire": { + "enum": [ + "allow", + "deny", + "ask" + ], + "type": "string" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "decision": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUseDecisionWire" + } + ], + "default": null + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUseHookSpecificOutputWire" + } + ], + "default": null + }, + "reason": { + "default": null, + "type": "string" + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "pre-tool-use.command.output", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json index 292777ff67..f44928983d 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -4,6 +4,7 @@ "definitions": { "HookEventNameWire": { "enum": [ + "PreToolUse", "SessionStart", "UserPromptSubmit", "Stop" diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json index c6935aa6da..27878752c1 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json @@ -10,6 +10,7 @@ }, "HookEventNameWire": { "enum": [ + "PreToolUse", "SessionStart", "UserPromptSubmit", "Stop" diff --git a/codex-rs/hooks/src/engine/config.rs b/codex-rs/hooks/src/engine/config.rs index 0d9357e392..1a2d962bc8 100644 --- a/codex-rs/hooks/src/engine/config.rs +++ b/codex-rs/hooks/src/engine/config.rs @@ -8,6 +8,8 @@ pub(crate) struct HooksFile { #[derive(Debug, Default, Deserialize)] pub(crate) struct HookEvents { + #[serde(rename = "PreToolUse", default)] + pub pre_tool_use: Vec, #[serde(rename = "SessionStart", default)] pub session_start: Vec, #[serde(rename = "UserPromptSubmit", default)] diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index db0f38c645..55dfca63cd 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -3,11 +3,12 @@ use std::path::Path; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; -use regex::Regex; use super::ConfiguredHandler; use super::config::HookHandlerConfig; use super::config::HooksFile; +use crate::events::common::matcher_pattern_for_event; +use crate::events::common::validate_matcher_pattern; pub(crate) struct DiscoveryResult { pub handlers: Vec, @@ -69,6 +70,21 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - } }; + for group in parsed.hooks.pre_tool_use { + append_group_handlers( + &mut handlers, + &mut warnings, + &mut display_order, + source_path.as_path(), + codex_protocol::protocol::HookEventName::PreToolUse, + matcher_pattern_for_event( + codex_protocol::protocol::HookEventName::PreToolUse, + group.matcher.as_deref(), + ), + group.hooks, + ); + } + for group in parsed.hooks.session_start { append_group_handlers( &mut handlers, @@ -76,7 +92,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut display_order, source_path.as_path(), codex_protocol::protocol::HookEventName::SessionStart, - effective_matcher( + matcher_pattern_for_event( codex_protocol::protocol::HookEventName::SessionStart, group.matcher.as_deref(), ), @@ -91,7 +107,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut display_order, source_path.as_path(), codex_protocol::protocol::HookEventName::UserPromptSubmit, - effective_matcher( + matcher_pattern_for_event( codex_protocol::protocol::HookEventName::UserPromptSubmit, group.matcher.as_deref(), ), @@ -106,7 +122,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut display_order, source_path.as_path(), codex_protocol::protocol::HookEventName::Stop, - effective_matcher( + matcher_pattern_for_event( codex_protocol::protocol::HookEventName::Stop, group.matcher.as_deref(), ), @@ -118,17 +134,6 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - DiscoveryResult { handlers, warnings } } -fn effective_matcher( - event_name: codex_protocol::protocol::HookEventName, - matcher: Option<&str>, -) -> Option<&str> { - match event_name { - codex_protocol::protocol::HookEventName::SessionStart => matcher, - codex_protocol::protocol::HookEventName::UserPromptSubmit - | codex_protocol::protocol::HookEventName::Stop => None, - } -} - fn append_group_handlers( handlers: &mut Vec, warnings: &mut Vec, @@ -139,7 +144,7 @@ fn append_group_handlers( group_handlers: Vec, ) { if let Some(matcher) = matcher - && let Err(err) = Regex::new(matcher) + && let Err(err) = validate_matcher_pattern(matcher) { warnings.push(format!( "invalid matcher {matcher:?} in {}: {err}", @@ -205,7 +210,7 @@ mod tests { use super::ConfiguredHandler; use super::HookHandlerConfig; use super::append_group_handlers; - use super::effective_matcher; + use crate::events::common::matcher_pattern_for_event; #[test] fn user_prompt_submit_ignores_invalid_matcher_during_discovery() { @@ -219,7 +224,7 @@ mod tests { &mut display_order, Path::new("/tmp/hooks.json"), HookEventName::UserPromptSubmit, - effective_matcher(HookEventName::UserPromptSubmit, Some("[")), + matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("[")), vec![HookHandlerConfig::Command { command: "echo hello".to_string(), timeout_sec: None, @@ -242,4 +247,66 @@ mod tests { }] ); } + + #[test] + fn pre_tool_use_keeps_valid_matcher_during_discovery() { + let mut handlers = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + + append_group_handlers( + &mut handlers, + &mut warnings, + &mut display_order, + Path::new("/tmp/hooks.json"), + HookEventName::PreToolUse, + matcher_pattern_for_event(HookEventName::PreToolUse, Some("^Bash$")), + vec![HookHandlerConfig::Command { + command: "echo hello".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!( + handlers, + vec![ConfiguredHandler { + event_name: HookEventName::PreToolUse, + matcher: Some("^Bash$".to_string()), + command: "echo hello".to_string(), + timeout_sec: 600, + status_message: None, + source_path: PathBuf::from("/tmp/hooks.json"), + display_order: 0, + }] + ); + } + + #[test] + fn pre_tool_use_treats_star_matcher_as_match_all() { + let mut handlers = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + + append_group_handlers( + &mut handlers, + &mut warnings, + &mut display_order, + Path::new("/tmp/hooks.json"), + HookEventName::PreToolUse, + matcher_pattern_for_event(HookEventName::PreToolUse, Some("*")), + vec![HookHandlerConfig::Command { + command: "echo hello".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!(handlers.len(), 1); + assert_eq!(handlers[0].matcher.as_deref(), Some("*")); + } } diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index e316d9af98..0b29e12fa0 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -14,6 +14,7 @@ use super::CommandShell; use super::ConfiguredHandler; use super::command_runner::CommandRunResult; use super::command_runner::run_command; +use crate::events::common::matches_matcher; #[derive(Debug)] pub(crate) struct ParsedHandler { @@ -30,13 +31,9 @@ pub(crate) fn select_handlers( .iter() .filter(|handler| handler.event_name == event_name) .filter(|handler| match event_name { - HookEventName::SessionStart => match (&handler.matcher, matcher_input) { - (Some(matcher), Some(input)) => regex::Regex::new(matcher) - .map(|regex| regex.is_match(input)) - .unwrap_or(false), - (None, _) => true, - _ => false, - }, + HookEventName::PreToolUse | HookEventName::SessionStart => { + matches_matcher(handler.matcher.as_deref(), matcher_input) + } HookEventName::UserPromptSubmit | HookEventName::Stop => true, }) .cloned() @@ -109,7 +106,9 @@ pub(crate) fn completed_summary( fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { HookEventName::SessionStart => HookScope::Thread, - HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn, + HookEventName::PreToolUse | HookEventName::UserPromptSubmit | HookEventName::Stop => { + HookScope::Turn + } } } @@ -172,6 +171,50 @@ mod tests { assert_eq!(selected[1].display_order, 1); } + #[test] + fn pre_tool_use_matches_tool_name() { + let handlers = vec![ + make_handler(HookEventName::PreToolUse, Some("^Bash$"), "echo same", 0), + make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1), + ]; + + let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].display_order, 0); + } + + #[test] + fn pre_tool_use_star_matcher_matches_all_tools() { + let handlers = vec![ + make_handler(HookEventName::PreToolUse, Some("*"), "echo same", 0), + make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1), + ]; + + let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].display_order, 0); + } + + #[test] + fn pre_tool_use_regex_alternation_matches_each_tool_name() { + let handlers = vec![make_handler( + HookEventName::PreToolUse, + Some("Edit|Write"), + "echo same", + 0, + )]; + + let selected_edit = select_handlers(&handlers, HookEventName::PreToolUse, Some("Edit")); + let selected_write = select_handlers(&handlers, HookEventName::PreToolUse, Some("Write")); + let selected_bash = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + + assert_eq!(selected_edit.len(), 1); + assert_eq!(selected_write.len(), 1); + assert_eq!(selected_bash.len(), 0); + } + #[test] fn user_prompt_submit_ignores_matcher() { let handlers = vec![ diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 24ff72990e..f54403b74b 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -10,6 +10,8 @@ use std::path::PathBuf; use codex_config::ConfigLayerStack; use codex_protocol::protocol::HookRunSummary; +use crate::events::pre_tool_use::PreToolUseOutcome; +use crate::events::pre_tool_use::PreToolUseRequest; use crate::events::session_start::SessionStartOutcome; use crate::events::session_start::SessionStartRequest; use crate::events::stop::StopOutcome; @@ -46,6 +48,7 @@ impl ConfiguredHandler { fn event_name_label(&self) -> &'static str { match self.event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use", codex_protocol::protocol::HookEventName::SessionStart => "session-start", codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit", codex_protocol::protocol::HookEventName::Stop => "stop", @@ -105,6 +108,10 @@ impl ClaudeHooksEngine { crate::events::session_start::preview(&self.handlers, request) } + pub(crate) fn preview_pre_tool_use(&self, request: &PreToolUseRequest) -> Vec { + crate::events::pre_tool_use::preview(&self.handlers, request) + } + pub(crate) async fn run_session_start( &self, request: SessionStartRequest, @@ -113,6 +120,10 @@ impl ClaudeHooksEngine { crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await } + pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome { + crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await + } + pub(crate) fn preview_user_prompt_submit( &self, request: &UserPromptSubmitRequest, diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs index d72ae07155..3fc0e7a0ba 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -12,6 +12,13 @@ pub(crate) struct SessionStartOutput { pub additional_context: Option, } +#[derive(Debug, Clone)] +pub(crate) struct PreToolUseOutput { + pub universal: UniversalOutput, + pub block_reason: Option, + pub invalid_reason: Option, +} + #[derive(Debug, Clone)] pub(crate) struct UserPromptSubmitOutput { pub universal: UniversalOutput, @@ -31,6 +38,9 @@ pub(crate) struct StopOutput { use crate::schema::BlockDecisionWire; use crate::schema::HookUniversalOutputWire; +use crate::schema::PreToolUseCommandOutputWire; +use crate::schema::PreToolUseDecisionWire; +use crate::schema::PreToolUsePermissionDecisionWire; use crate::schema::SessionStartCommandOutputWire; use crate::schema::StopCommandOutputWire; use crate::schema::UserPromptSubmitCommandOutputWire; @@ -46,6 +56,54 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option { }) } +pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option { + let PreToolUseCommandOutputWire { + universal: universal_wire, + decision, + reason, + hook_specific_output, + } = parse_json(stdout)?; + let universal = UniversalOutput::from(universal_wire); + let hook_specific_output = hook_specific_output.as_ref(); + let use_hook_specific_decision = hook_specific_output.is_some_and(|output| { + output.permission_decision.is_some() + || output.permission_decision_reason.is_some() + || output.updated_input.is_some() + || output.additional_context.is_some() + }); + let invalid_reason = unsupported_pre_tool_use_universal(&universal).or_else(|| { + if use_hook_specific_decision { + hook_specific_output.and_then(unsupported_pre_tool_use_hook_specific_output) + } else { + unsupported_pre_tool_use_legacy_decision(decision.as_ref(), reason.as_deref()) + } + }); + let block_reason = if invalid_reason.is_none() { + if use_hook_specific_decision { + hook_specific_output.and_then(|output| match output.permission_decision { + Some(PreToolUsePermissionDecisionWire::Deny) => output + .permission_decision_reason + .as_deref() + .and_then(trimmed_reason), + _ => None, + }) + } else { + match decision.as_ref() { + Some(PreToolUseDecisionWire::Block) => reason.as_deref().and_then(trimmed_reason), + Some(PreToolUseDecisionWire::Approve) | None => None, + } + } + } else { + None + }; + + Some(PreToolUseOutput { + universal, + block_reason, + invalid_reason, + }) +} + pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option { let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?; let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block)); @@ -119,3 +177,97 @@ where fn invalid_block_message(event_name: &str) -> String { format!("{event_name} hook returned decision:block without a non-empty reason") } + +fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option { + if !universal.continue_processing { + Some("PreToolUse hook returned unsupported continue:false".to_string()) + } else if universal.stop_reason.is_some() { + Some("PreToolUse hook returned unsupported stopReason".to_string()) + } else if universal.suppress_output { + Some("PreToolUse hook returned unsupported suppressOutput".to_string()) + } else { + None + } +} + +fn unsupported_pre_tool_use_hook_specific_output( + output: &crate::schema::PreToolUseHookSpecificOutputWire, +) -> Option { + if output.updated_input.is_some() { + Some("PreToolUse hook returned unsupported updatedInput".to_string()) + } else if output + .additional_context + .as_deref() + .and_then(trimmed_reason) + .is_some() + { + Some("PreToolUse hook returned unsupported additionalContext".to_string()) + } else { + match output.permission_decision { + Some(PreToolUsePermissionDecisionWire::Allow) => { + Some("PreToolUse hook returned unsupported permissionDecision:allow".to_string()) + } + Some(PreToolUsePermissionDecisionWire::Ask) => { + Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string()) + } + Some(PreToolUsePermissionDecisionWire::Deny) => { + if output + .permission_decision_reason + .as_deref() + .and_then(trimmed_reason) + .is_none() + { + Some(invalid_pre_tool_use_reason_message()) + } else { + None + } + } + None => { + if output.permission_decision_reason.is_some() { + Some("PreToolUse hook returned permissionDecisionReason without permissionDecision".to_string()) + } else { + None + } + } + } + } +} + +fn unsupported_pre_tool_use_legacy_decision( + decision: Option<&PreToolUseDecisionWire>, + reason: Option<&str>, +) -> Option { + match decision { + Some(PreToolUseDecisionWire::Approve) => { + Some("PreToolUse hook returned unsupported decision:approve".to_string()) + } + Some(PreToolUseDecisionWire::Block) => { + if reason.and_then(trimmed_reason).is_none() { + Some(invalid_block_message("PreToolUse")) + } else { + None + } + } + None => { + if reason.is_some() { + Some("PreToolUse hook returned reason without decision".to_string()) + } else { + None + } + } + } +} + +fn invalid_pre_tool_use_reason_message() -> String { + "PreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason" + .to_string() +} + +fn trimmed_reason(reason: &str) -> Option { + let trimmed = reason.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index 2ad54e5062..1a51e59527 100644 --- a/codex-rs/hooks/src/engine/schema_loader.rs +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -4,6 +4,8 @@ use serde_json::Value; #[allow(dead_code)] pub(crate) struct GeneratedHookSchemas { + pub pre_tool_use_command_input: Value, + pub pre_tool_use_command_output: Value, pub session_start_command_input: Value, pub session_start_command_output: Value, pub user_prompt_submit_command_input: Value, @@ -15,6 +17,14 @@ pub(crate) struct GeneratedHookSchemas { pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas { static SCHEMAS: OnceLock = OnceLock::new(); SCHEMAS.get_or_init(|| GeneratedHookSchemas { + pre_tool_use_command_input: parse_json_schema( + "pre-tool-use.command.input", + include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"), + ), + pre_tool_use_command_output: parse_json_schema( + "pre-tool-use.command.output", + include_str!("../../schema/generated/pre-tool-use.command.output.schema.json"), + ), session_start_command_input: parse_json_schema( "session-start.command.input", include_str!("../../schema/generated/session-start.command.input.schema.json"), @@ -56,6 +66,8 @@ mod tests { fn loads_generated_hook_schemas() { let schemas = generated_hook_schemas(); + assert_eq!(schemas.pre_tool_use_command_input["type"], "object"); + assert_eq!(schemas.pre_tool_use_command_output["type"], "object"); assert_eq!(schemas.session_start_command_input["type"], "object"); assert_eq!(schemas.session_start_command_output["type"], "object"); assert_eq!(schemas.user_prompt_submit_command_input["type"], "object"); diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index b6358e068a..b4c274bbd3 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -1,4 +1,5 @@ use codex_protocol::protocol::HookCompletedEvent; +use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookOutputEntry; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; @@ -67,3 +68,113 @@ pub(crate) fn serialization_failure_hook_events( }) .collect() } + +pub(crate) fn matcher_pattern_for_event( + event_name: HookEventName, + matcher: Option<&str>, +) -> Option<&str> { + match event_name { + HookEventName::PreToolUse | HookEventName::SessionStart => matcher, + HookEventName::UserPromptSubmit | HookEventName::Stop => None, + } +} + +pub(crate) fn validate_matcher_pattern(matcher: &str) -> Result<(), regex::Error> { + if is_match_all_matcher(matcher) { + return Ok(()); + } + regex::Regex::new(matcher).map(|_| ()) +} + +pub(crate) fn matches_matcher(matcher: Option<&str>, input: Option<&str>) -> bool { + match matcher { + None => true, + Some(matcher) if is_match_all_matcher(matcher) => true, + Some(matcher) => input + .and_then(|input| { + regex::Regex::new(matcher) + .ok() + .map(|regex| regex.is_match(input)) + }) + .unwrap_or(false), + } +} + +fn is_match_all_matcher(matcher: &str) -> bool { + matcher.is_empty() || matcher == "*" +} + +#[cfg(test)] +mod tests { + use codex_protocol::protocol::HookEventName; + use pretty_assertions::assert_eq; + + use super::matcher_pattern_for_event; + use super::matches_matcher; + use super::validate_matcher_pattern; + + #[test] + fn matcher_omitted_matches_all_occurrences() { + assert!(matches_matcher(None, Some("Bash"))); + assert!(matches_matcher(None, Some("Write"))); + } + + #[test] + fn matcher_star_matches_all_occurrences() { + assert!(matches_matcher(Some("*"), Some("Bash"))); + assert!(matches_matcher(Some("*"), Some("Edit"))); + assert_eq!(validate_matcher_pattern("*"), Ok(())); + } + + #[test] + fn matcher_empty_string_matches_all_occurrences() { + assert!(matches_matcher(Some(""), Some("Bash"))); + assert!(matches_matcher(Some(""), Some("SessionStart"))); + assert_eq!(validate_matcher_pattern(""), Ok(())); + } + + #[test] + fn matcher_uses_regex_matching() { + assert!(matches_matcher(Some("Edit|Write"), Some("Edit"))); + assert!(matches_matcher(Some("Edit|Write"), Some("Write"))); + assert!(!matches_matcher(Some("Edit|Write"), Some("Bash"))); + assert_eq!(validate_matcher_pattern("Edit|Write"), Ok(())); + } + + #[test] + fn matcher_supports_anchored_regexes() { + assert!(matches_matcher(Some("^Bash$"), Some("Bash"))); + assert!(!matches_matcher(Some("^Bash$"), Some("BashOutput"))); + assert_eq!(validate_matcher_pattern("^Bash$"), Ok(())); + } + + #[test] + fn invalid_regex_is_rejected() { + assert!(validate_matcher_pattern("[").is_err()); + assert!(!matches_matcher(Some("["), Some("Bash"))); + } + + #[test] + fn unsupported_events_ignore_matchers() { + assert_eq!( + matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("^hello")), + None + ); + assert_eq!( + matcher_pattern_for_event(HookEventName::Stop, Some("^done$")), + None + ); + } + + #[test] + fn supported_events_keep_matchers() { + assert_eq!( + matcher_pattern_for_event(HookEventName::PreToolUse, Some("Bash")), + Some("Bash") + ); + assert_eq!( + matcher_pattern_for_event(HookEventName::SessionStart, Some("startup|resume")), + Some("startup|resume") + ); + } +} diff --git a/codex-rs/hooks/src/events/mod.rs b/codex-rs/hooks/src/events/mod.rs index 3bb54699af..603395eb5a 100644 --- a/codex-rs/hooks/src/events/mod.rs +++ b/codex-rs/hooks/src/events/mod.rs @@ -1,4 +1,5 @@ -mod common; +pub(crate) mod common; +pub mod pre_tool_use; pub mod session_start; pub mod stop; pub mod user_prompt_submit; diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs new file mode 100644 index 0000000000..8366bb632c --- /dev/null +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -0,0 +1,479 @@ +use std::path::PathBuf; + +use codex_protocol::ThreadId; +use codex_protocol::protocol::HookCompletedEvent; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookOutputEntry; +use codex_protocol::protocol::HookOutputEntryKind; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookRunSummary; + +use super::common; +use crate::engine::CommandShell; +use crate::engine::ConfiguredHandler; +use crate::engine::command_runner::CommandRunResult; +use crate::engine::dispatcher; +use crate::engine::output_parser; +use crate::schema::PreToolUseCommandInput; + +#[derive(Debug, Clone)] +pub struct PreToolUseRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: PathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub tool_name: String, + pub tool_use_id: String, + pub command: String, +} + +#[derive(Debug)] +pub struct PreToolUseOutcome { + pub hook_events: Vec, + pub should_block: bool, + pub block_reason: Option, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct PreToolUseHandlerData { + should_block: bool, + block_reason: Option, +} + +pub(crate) fn preview( + handlers: &[ConfiguredHandler], + request: &PreToolUseRequest, +) -> Vec { + dispatcher::select_handlers( + handlers, + HookEventName::PreToolUse, + Some(&request.tool_name), + ) + .into_iter() + .map(|handler| dispatcher::running_summary(&handler)) + .collect() +} + +pub(crate) async fn run( + handlers: &[ConfiguredHandler], + shell: &CommandShell, + request: PreToolUseRequest, +) -> PreToolUseOutcome { + let matched = dispatcher::select_handlers( + handlers, + HookEventName::PreToolUse, + Some(&request.tool_name), + ); + if matched.is_empty() { + return PreToolUseOutcome { + hook_events: Vec::new(), + should_block: false, + block_reason: None, + }; + } + + let input_json = match serde_json::to_string(&PreToolUseCommandInput { + session_id: request.session_id.to_string(), + turn_id: request.turn_id.clone(), + transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), + cwd: request.cwd.display().to_string(), + hook_event_name: "PreToolUse".to_string(), + model: request.model.clone(), + permission_mode: request.permission_mode.clone(), + tool_name: "Bash".to_string(), + tool_input: crate::schema::PreToolUseToolInput { + command: request.command.clone(), + }, + tool_use_id: request.tool_use_id.clone(), + }) { + Ok(input_json) => input_json, + Err(error) => { + return serialization_failure_outcome(common::serialization_failure_hook_events( + matched, + Some(request.turn_id), + format!("failed to serialize pre tool use hook input: {error}"), + )); + } + }; + + let results = dispatcher::execute_handlers( + shell, + matched, + input_json, + request.cwd.as_path(), + Some(request.turn_id), + parse_completed, + ) + .await; + + let should_block = results.iter().any(|result| result.data.should_block); + let block_reason = results + .iter() + .find_map(|result| result.data.block_reason.clone()); + + PreToolUseOutcome { + hook_events: results.into_iter().map(|result| result.completed).collect(), + should_block, + block_reason, + } +} + +fn parse_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler { + let mut entries = Vec::new(); + let mut status = HookRunStatus::Completed; + let mut should_block = false; + let mut block_reason = None; + + match run_result.error.as_deref() { + Some(error) => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: error.to_string(), + }); + } + None => match run_result.exit_code { + Some(0) => { + let trimmed_stdout = run_result.stdout.trim(); + if trimmed_stdout.is_empty() { + } else if let Some(parsed) = output_parser::parse_pre_tool_use(&run_result.stdout) { + if let Some(system_message) = parsed.universal.system_message { + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Warning, + text: system_message, + }); + } + if let Some(invalid_reason) = parsed.invalid_reason { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: invalid_reason, + }); + } else if let Some(reason) = parsed.block_reason { + status = HookRunStatus::Blocked; + should_block = true; + block_reason = Some(reason.clone()); + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: reason, + }); + } + } else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook returned invalid pre-tool-use JSON output".to_string(), + }); + } + } + Some(2) => { + if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) { + status = HookRunStatus::Blocked; + should_block = true; + block_reason = Some(reason.clone()); + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: reason, + }); + } else { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook exited with code 2 but did not write a blocking reason to stderr".to_string(), + }); + } + } + Some(exit_code) => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: format!("hook exited with code {exit_code}"), + }); + } + None => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook exited without a status code".to_string(), + }); + } + }, + } + + let completed = HookCompletedEvent { + turn_id, + run: dispatcher::completed_summary(handler, &run_result, status, entries), + }; + + dispatcher::ParsedHandler { + completed, + data: PreToolUseHandlerData { + should_block, + block_reason, + }, + } +} + +fn serialization_failure_outcome(hook_events: Vec) -> PreToolUseOutcome { + PreToolUseOutcome { + hook_events, + should_block: false, + block_reason: None, + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use codex_protocol::protocol::HookEventName; + use codex_protocol::protocol::HookOutputEntry; + use codex_protocol::protocol::HookOutputEntryKind; + use codex_protocol::protocol::HookRunStatus; + use pretty_assertions::assert_eq; + + use super::PreToolUseHandlerData; + use super::parse_completed; + use crate::engine::ConfiguredHandler; + use crate::engine::command_runner::CommandRunResult; + + #[test] + fn permission_decision_deny_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("do not run that".to_string()), + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "do not run that".to_string(), + }] + ); + } + + #[test] + fn deprecated_block_decision_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"decision":"block","reason":"do not run that"}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("do not run that".to_string()), + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "do not run that".to_string(), + }] + ); + } + + #[test] + fn unsupported_permission_decision_fails_open() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"please confirm"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook returned unsupported permissionDecision:ask".to_string(), + }] + ); + } + + #[test] + fn deprecated_approve_decision_fails_open() { + let parsed = parse_completed( + &handler(), + run_result(Some(0), r#"{"decision":"approve"}"#, ""), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook returned unsupported decision:approve".to_string(), + }] + ); + } + + #[test] + fn unsupported_additional_context_fails_open() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that","additionalContext":"nope"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook returned unsupported additionalContext".to_string(), + }] + ); + } + + #[test] + fn plain_stdout_is_ignored() { + let parsed = parse_completed( + &handler(), + run_result(Some(0), "hook ran successfully\n", ""), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Completed); + assert_eq!(parsed.completed.run.entries, vec![]); + } + + #[test] + fn invalid_json_like_stdout_fails_instead_of_becoming_noop() { + let parsed = parse_completed( + &handler(), + run_result(Some(0), "{\"decision\":\n", ""), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook returned invalid pre-tool-use JSON output".to_string(), + }] + ); + } + + #[test] + fn exit_code_two_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result(Some(2), "", "blocked by policy\n"), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("blocked by policy".to_string()), + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "blocked by policy".to_string(), + }] + ); + } + + fn handler() -> ConfiguredHandler { + ConfiguredHandler { + event_name: HookEventName::PreToolUse, + matcher: Some("^Bash$".to_string()), + command: "echo hook".to_string(), + timeout_sec: 5, + status_message: None, + source_path: PathBuf::from("/tmp/hooks.json"), + display_order: 0, + } + } + + fn run_result(exit_code: Option, stdout: &str, stderr: &str) -> CommandRunResult { + CommandRunResult { + started_at: 1, + completed_at: 2, + duration_ms: 1, + exit_code, + stdout: stdout.to_string(), + stderr: stderr.to_string(), + error: None, + } + } +} diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 768a24c5e3..86b39b0b7d 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -5,6 +5,8 @@ mod registry; mod schema; mod types; +pub use events::pre_tool_use::PreToolUseOutcome; +pub use events::pre_tool_use::PreToolUseRequest; pub use events::session_start::SessionStartOutcome; pub use events::session_start::SessionStartRequest; pub use events::session_start::SessionStartSource; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 3b63bda8c3..440af648dd 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -3,6 +3,8 @@ use tokio::process::Command; use crate::engine::ClaudeHooksEngine; use crate::engine::CommandShell; +use crate::events::pre_tool_use::PreToolUseOutcome; +use crate::events::pre_tool_use::PreToolUseRequest; use crate::events::session_start::SessionStartOutcome; use crate::events::session_start::SessionStartRequest; use crate::events::stop::StopOutcome; @@ -92,6 +94,13 @@ impl Hooks { self.engine.preview_session_start(request) } + pub fn preview_pre_tool_use( + &self, + request: &PreToolUseRequest, + ) -> Vec { + self.engine.preview_pre_tool_use(request) + } + pub async fn run_session_start( &self, request: SessionStartRequest, @@ -100,6 +109,10 @@ impl Hooks { self.engine.run_session_start(request, turn_id).await } + pub async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome { + self.engine.run_pre_tool_use(request).await + } + pub fn preview_user_prompt_submit( &self, request: &UserPromptSubmitRequest, diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index 067658541a..277500984c 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -13,6 +13,8 @@ use std::path::Path; use std::path::PathBuf; const GENERATED_DIR: &str = "generated"; +const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json"; +const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json"; const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json"; const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json"; const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json"; @@ -63,6 +65,8 @@ pub(crate) struct HookUniversalOutputWire { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub(crate) enum HookEventNameWire { + #[serde(rename = "PreToolUse")] + PreToolUse, #[serde(rename = "SessionStart")] SessionStart, #[serde(rename = "UserPromptSubmit")] @@ -71,6 +75,81 @@ pub(crate) enum HookEventNameWire { Stop, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[schemars(rename = "pre-tool-use.command.output")] +pub(crate) struct PreToolUseCommandOutputWire { + #[serde(flatten)] + pub universal: HookUniversalOutputWire, + #[serde(default)] + pub decision: Option, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub hook_specific_output: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub(crate) struct PreToolUseHookSpecificOutputWire { + pub hook_event_name: HookEventNameWire, + #[serde(default)] + pub permission_decision: Option, + #[serde(default)] + pub permission_decision_reason: Option, + #[serde(default)] + pub updated_input: Option, + #[serde(default)] + pub additional_context: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub(crate) enum PreToolUsePermissionDecisionWire { + #[serde(rename = "allow")] + Allow, + #[serde(rename = "deny")] + Deny, + #[serde(rename = "ask")] + Ask, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub(crate) enum PreToolUseDecisionWire { + #[serde(rename = "approve")] + Approve, + #[serde(rename = "block")] + Block, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub(crate) struct PreToolUseToolInput { + pub command: String, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "pre-tool-use.command.input")] +pub(crate) struct PreToolUseCommandInput { + pub session_id: String, + /// Codex extension: expose the active turn id to internal turn-scoped hooks. + pub turn_id: String, + pub transcript_path: NullableString, + pub cwd: String, + #[schemars(schema_with = "pre_tool_use_hook_event_name_schema")] + pub hook_event_name: String, + pub model: String, + #[schemars(schema_with = "permission_mode_schema")] + pub permission_mode: String, + #[schemars(schema_with = "pre_tool_use_tool_name_schema")] + pub tool_name: String, + pub tool_input: PreToolUseToolInput, + pub tool_use_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -212,6 +291,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { let generated_dir = schema_root.join(GENERATED_DIR); ensure_empty_dir(&generated_dir)?; + write_schema( + &generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(PRE_TOOL_USE_OUTPUT_FIXTURE), + schema_json::()?, + )?; write_schema( &generated_dir.join(SESSION_START_INPUT_FIXTURE), schema_json::()?, @@ -295,6 +382,14 @@ fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("SessionStart") } +fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("PreToolUse") +} + +fn pre_tool_use_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("Bash") +} + fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("UserPromptSubmit") } @@ -346,6 +441,9 @@ fn default_continue() -> bool { #[cfg(test)] mod tests { + use super::PRE_TOOL_USE_INPUT_FIXTURE; + use super::PRE_TOOL_USE_OUTPUT_FIXTURE; + use super::PreToolUseCommandInput; use super::SESSION_START_INPUT_FIXTURE; use super::SESSION_START_OUTPUT_FIXTURE; use super::STOP_INPUT_FIXTURE; @@ -362,6 +460,12 @@ mod tests { fn expected_fixture(name: &str) -> &'static str { match name { + PRE_TOOL_USE_INPUT_FIXTURE => { + include_str!("../schema/generated/pre-tool-use.command.input.schema.json") + } + PRE_TOOL_USE_OUTPUT_FIXTURE => { + include_str!("../schema/generated/pre-tool-use.command.output.schema.json") + } SESSION_START_INPUT_FIXTURE => { include_str!("../schema/generated/session-start.command.input.schema.json") } @@ -395,6 +499,8 @@ mod tests { write_schema_fixtures(&schema_root).expect("write generated hook schemas"); for fixture in [ + PRE_TOOL_USE_INPUT_FIXTURE, + PRE_TOOL_USE_OUTPUT_FIXTURE, SESSION_START_INPUT_FIXTURE, SESSION_START_OUTPUT_FIXTURE, USER_PROMPT_SUBMIT_INPUT_FIXTURE, @@ -414,6 +520,10 @@ mod tests { fn turn_scoped_hook_inputs_include_codex_turn_id_extension() { // Codex intentionally diverges from Claude's public hook docs here so // internal hook consumers can key off the active turn. + let pre_tool_use: Value = serde_json::from_slice( + &schema_json::().expect("serialize pre tool use input schema"), + ) + .expect("parse pre tool use input schema"); let user_prompt_submit: Value = serde_json::from_slice( &schema_json::() .expect("serialize user prompt submit input schema"), @@ -424,7 +534,7 @@ mod tests { ) .expect("parse stop input schema"); - for schema in [&user_prompt_submit, &stop] { + for schema in [&pre_tool_use, &user_prompt_submit, &stop] { assert_eq!(schema["properties"]["turn_id"]["type"], "string"); assert!( schema["required"] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c8af844f7b..09b5948262 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1425,6 +1425,7 @@ pub enum EventMsg { #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum HookEventName { + PreToolUse, SessionStart, UserPromptSubmit, Stop, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b6ccf71189..a553240170 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -9603,6 +9603,7 @@ fn extract_first_bold(s: &str) -> Option { fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { match event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap similarity index 59% rename from codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap rename to codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap index 27474ef6d7..11fa8ab8e3 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap @@ -1,10 +1,9 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 8586 expression: combined --- -• Running SessionStart hook: warming the shell +• Running PreToolUse hook: warming the shell -SessionStart hook (completed) +PreToolUse hook (completed) warning: Heads up from the hook hook context: Remember the startup checklist. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__session_start_hook_events_render_snapshot.snap similarity index 91% rename from codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap rename to codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__session_start_hook_events_render_snapshot.snap index 27474ef6d7..30dfa78e5b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__session_start_hook_events_render_snapshot.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 8586 expression: combined --- • Running SessionStart hook: warming the shell diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 06eed8c8cc..2300421966 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -12052,7 +12052,33 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() { } #[tokio::test] -async fn hook_events_render_snapshot() { +async fn pre_tool_use_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_protocol::protocol::HookEventName::PreToolUse, + "pre-tool-use:0:/tmp/hooks.json", + "warming the shell", + "pre_tool_use_hook_events_render_snapshot", + ) + .await; +} + +#[tokio::test] +async fn session_start_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_protocol::protocol::HookEventName::SessionStart, + "session-start:0:/tmp/hooks.json", + "warming the shell", + "session_start_hook_events_render_snapshot", + ) + .await; +} + +async fn assert_hook_events_snapshot( + event_name: codex_protocol::protocol::HookEventName, + run_id: &str, + status_message: &str, + snapshot_name: &str, +) { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { @@ -12060,15 +12086,15 @@ async fn hook_events_render_snapshot() { msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { turn_id: None, run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, + id: run_id.to_string(), + event_name, handler_type: codex_protocol::protocol::HookHandlerType::Command, execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, + scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from("/tmp/hooks.json"), display_order: 0, status: codex_protocol::protocol::HookRunStatus::Running, - status_message: Some("warming the shell".to_string()), + status_message: Some(status_message.to_string()), started_at: 1, completed_at: None, duration_ms: None, @@ -12082,15 +12108,15 @@ async fn hook_events_render_snapshot() { msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { turn_id: None, run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, + id: run_id.to_string(), + event_name, handler_type: codex_protocol::protocol::HookHandlerType::Command, execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, + scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from("/tmp/hooks.json"), display_order: 0, status: codex_protocol::protocol::HookRunStatus::Completed, - status_message: Some("warming the shell".to_string()), + status_message: Some(status_message.to_string()), started_at: 1, completed_at: Some(11), duration_ms: Some(10), @@ -12113,7 +12139,7 @@ async fn hook_events_render_snapshot() { .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); - assert_snapshot!("hook_events_render_snapshot", combined); + assert_snapshot!(snapshot_name, combined); } // Combined visual snapshot using vt100 for history + direct buffer overlay for UI. diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 4796401cc9..9363e3e356 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -10774,6 +10774,7 @@ fn extract_first_bold(s: &str) -> Option { fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { match event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap new file mode 100644 index 0000000000..f9a87c5682 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap @@ -0,0 +1,9 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: combined +--- +• Running PreToolUse hook: warming the shell + +PreToolUse hook (completed) + warning: Heads up from the hook + hook context: Remember the startup checklist. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__hook_events_render_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__session_start_hook_events_render_snapshot.snap similarity index 100% rename from codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__hook_events_render_snapshot.snap rename to codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__session_start_hook_events_render_snapshot.snap diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index df229f4c85..611bf13adc 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -12465,7 +12465,33 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() { } #[tokio::test] -async fn hook_events_render_snapshot() { +async fn pre_tool_use_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_protocol::protocol::HookEventName::PreToolUse, + "pre-tool-use:0:/tmp/hooks.json", + "warming the shell", + "pre_tool_use_hook_events_render_snapshot", + ) + .await; +} + +#[tokio::test] +async fn session_start_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_protocol::protocol::HookEventName::SessionStart, + "session-start:0:/tmp/hooks.json", + "warming the shell", + "session_start_hook_events_render_snapshot", + ) + .await; +} + +async fn assert_hook_events_snapshot( + event_name: codex_protocol::protocol::HookEventName, + run_id: &str, + status_message: &str, + snapshot_name: &str, +) { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { @@ -12473,15 +12499,15 @@ async fn hook_events_render_snapshot() { msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { turn_id: None, run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, + id: run_id.to_string(), + event_name, handler_type: codex_protocol::protocol::HookHandlerType::Command, execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, + scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from("/tmp/hooks.json"), display_order: 0, status: codex_protocol::protocol::HookRunStatus::Running, - status_message: Some("warming the shell".to_string()), + status_message: Some(status_message.to_string()), started_at: 1, completed_at: None, duration_ms: None, @@ -12495,15 +12521,15 @@ async fn hook_events_render_snapshot() { msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { turn_id: None, run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, + id: run_id.to_string(), + event_name, handler_type: codex_protocol::protocol::HookHandlerType::Command, execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, + scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from("/tmp/hooks.json"), display_order: 0, status: codex_protocol::protocol::HookRunStatus::Completed, - status_message: Some("warming the shell".to_string()), + status_message: Some(status_message.to_string()), started_at: 1, completed_at: Some(11), duration_ms: Some(10), @@ -12526,7 +12552,7 @@ async fn hook_events_render_snapshot() { .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); - assert_snapshot!("hook_events_render_snapshot", combined); + assert_snapshot!(snapshot_name, combined); } // Combined visual snapshot using vt100 for history + direct buffer overlay for UI. From 191fd9fd16e8f4ea43adb438122fb13f1b2ed674 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 23 Mar 2026 22:09:55 +0000 Subject: [PATCH 27/38] feat: use serde to differenciate inter agent communication (#15560) Use `serde` to encode the inter agent communication to an assistant message and use the decode to see if this is such a message Note: this assume serde on small pattern is fast enough --- .../src/codex/rollout_reconstruction_tests.rs | 16 +++++-- .../core/src/context_manager/history_tests.rs | 27 ++++++++--- .../src/tools/handlers/multi_agents_tests.rs | 26 +++++++--- codex-rs/protocol/src/protocol.rs | 48 ++++--------------- 4 files changed, 62 insertions(+), 55 deletions(-) diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 09b4f45613..96423754db 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -3,9 +3,11 @@ use super::*; use crate::protocol::CompactedItem; use crate::protocol::InitialHistory; use crate::protocol::ResumedHistory; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InterAgentCommunication; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -38,7 +40,15 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem { id: None, role: "assistant".to_string(), content: vec![ContentItem::OutputText { - text: text.to_string(), + text: serde_json::to_string(&InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root() + .join("worker") + .expect("worker path should be valid"), + Vec::new(), + text.to_string(), + )) + .expect("inter-agent communication should serialize"), }], end_turn: None, phase: None, @@ -455,9 +465,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { turn_id: Some(assistant_turn_id.clone()), ..first_context_item.clone() }; - let assistant_instruction = inter_agent_assistant_message( - "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", - ); + let assistant_instruction = inter_agent_assistant_message("continue"); let assistant_reply = assistant_message("worker reply"); let rollout_items = vec![ diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 4deb76ed6d..1a4dc0ed8e 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -4,6 +4,7 @@ use crate::truncate::TruncationPolicy; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_git::GhostCommit; +use codex_protocol::AgentPath; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; @@ -17,6 +18,7 @@ use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::InterAgentCommunication; use image::ImageBuffer; use image::ImageFormat; use image::Rgba; @@ -39,11 +41,17 @@ fn assistant_msg(text: &str) -> ResponseItem { } fn inter_agent_assistant_msg(text: &str) -> ResponseItem { + let communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root().join("worker").unwrap(), + Vec::new(), + text.to_string(), + ); ResponseItem::Message { id: None, role: "assistant".to_string(), content: vec![ContentItem::OutputText { - text: text.to_string(), + text: serde_json::to_string(&communication).unwrap(), }], end_turn: None, phase: None, @@ -239,9 +247,7 @@ fn items_after_last_model_generated_tokens_are_zero_without_model_generated_item #[test] fn inter_agent_assistant_messages_are_turn_boundaries() { - let item = inter_agent_assistant_msg( - "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", - ); + let item = inter_agent_assistant_msg("continue"); assert!(is_user_turn_boundary(&item)); } @@ -250,9 +256,7 @@ fn inter_agent_assistant_messages_are_turn_boundaries() { fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() { let first_turn = user_input_text_msg("first"); let first_reply = assistant_msg("done"); - let inter_agent_turn = inter_agent_assistant_msg( - "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", - ); + let inter_agent_turn = inter_agent_assistant_msg("continue"); let inter_agent_reply = assistant_msg("worker reply"); let mut history = create_history_with_items(vec![ first_turn.clone(), @@ -266,6 +270,15 @@ fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_t assert_eq!(history.raw_items(), &vec![first_turn, first_reply]); } +#[test] +fn legacy_inter_agent_assistant_messages_are_not_turn_boundaries() { + let item = assistant_msg( + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", + ); + + assert!(!is_user_turn_boundary(&item)); +} + #[test] fn total_token_usage_includes_all_items_after_last_model_generated_item() { let mut history = create_history_with_items(vec![assistant_msg("already counted by API")]); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 7b8471f03e..f74ebf0577 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -32,6 +32,7 @@ use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; use codex_protocol::user_input::UserInput; use pretty_assertions::assert_eq; @@ -79,6 +80,16 @@ fn thread_manager() -> ThreadManager { ) } +fn inter_agent_message_text(recipient: &str, content: &str) -> String { + serde_json::to_string(&InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from(recipient).expect("recipient path should be valid"), + Vec::new(), + content.to_string(), + )) + .expect("inter-agent communication should serialize") +} + #[derive(Clone, Copy)] struct NeverEndingTask; @@ -390,6 +401,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( .get_thread(child_thread_id) .await .expect("child thread should exist"); + let expected_message = inter_agent_message_text("/root/test_process", "continue"); timeout(Duration::from_secs(2), async { loop { let history_items = child_thread @@ -407,8 +419,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( && content.iter().any(|content_item| matches!( content_item, ContentItem::OutputText { text } - if text - == "author: /root\nrecipient: /root/test_process\nother_recipients: []\nContent: continue" + if text == &expected_message )) ) }); @@ -508,6 +519,10 @@ async fn multi_agent_v2_send_input_accepts_structured_items() { .find(|(id, op)| *id == agent_id && *op == expected); assert_eq!(captured, Some((agent_id, expected))); + let expected_message = inter_agent_message_text( + "/root/worker", + "[mention:$drive](app://google_drive)\nread the folder", + ); timeout(Duration::from_secs(2), async { loop { let history_items = thread @@ -525,8 +540,7 @@ async fn multi_agent_v2_send_input_accepts_structured_items() { && content.iter().any(|content_item| matches!( content_item, ContentItem::OutputText { text } - if text - == "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: [mention:$drive](app://google_drive)\nread the folder" + if text == &expected_message )) ) }); @@ -650,6 +664,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( )) ))); + let expected_message = inter_agent_message_text("/root/worker", "continue"); timeout(Duration::from_secs(5), async { loop { let history_items = thread @@ -667,8 +682,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( && content.iter().any(|content_item| matches!( content_item, ContentItem::OutputText { text } - if text - == "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue" + if text == &expected_message )) ) }); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 09b5948262..e6b1ae79dc 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -530,54 +530,26 @@ impl InterAgentCommunication { } } - pub fn to_response_item(&self) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: self.as_text(), - }], - end_turn: None, - phase: None, - } - } - pub fn to_response_input_item(&self) -> ResponseInputItem { ResponseInputItem::Message { role: "assistant".to_string(), content: vec![ContentItem::OutputText { - text: self.as_text(), + text: serde_json::to_string(self).unwrap_or_default(), }], } } pub fn is_message_content(content: &[ContentItem]) -> bool { - content.iter().any(|content_item| match content_item { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - Self::is_instruction_text(text) + Self::from_message_content(content).is_some() + } + + fn from_message_content(content: &[ContentItem]) -> Option { + match content { + [ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => { + serde_json::from_str(text).ok() } - _ => false, - }) - } - - fn as_text(&self) -> String { - let other_recipients = self - .other_recipients - .iter() - .map(std::string::ToString::to_string) - .collect::>() - .join(", "); - format!( - "author: {}\nrecipient: {}\nother_recipients: [{other_recipients}]\nContent: {}", - self.author, self.recipient, self.content - ) - } - - fn is_instruction_text(text: &str) -> bool { - text.starts_with("author: ") - && text.contains("\nrecipient: ") - && text.contains("\nother_recipients: [") - && text.contains("]\nContent: ") + _ => None, + } } } From 67c1c7c054dc494044e091acf17b3f666d5b7658 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 23 Mar 2026 15:19:01 -0700 Subject: [PATCH 28/38] chore(core) Add approvals reviewer to UserTurn (#15426) ## Summary Adds support for approvals_reviewer to `Op::UserTurn` so we can migrate `[CodexMessageProcessor::turn_start]` to use Op::UserTurn ## Testing - [x] Adds quick test for the new field Co-authored-by: Codex --- codex-rs/core/src/codex.rs | 3 +- codex-rs/core/src/codex_tests.rs | 35 +++++++++++++++++++ codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 ++ codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 ++ codex-rs/core/tests/suite/compact.rs | 7 ++++ codex-rs/core/tests/suite/exec_policy.rs | 2 ++ codex-rs/core/tests/suite/image_rollout.rs | 2 ++ codex-rs/core/tests/suite/items.rs | 5 +++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 ++++++++ .../core/tests/suite/model_visible_layout.rs | 5 +++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++++++ codex-rs/core/tests/suite/prompt_caching.rs | 5 +++ codex-rs/core/tests/suite/remote_models.rs | 4 +++ .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 ++ codex-rs/core/tests/suite/rmcp_client.rs | 6 ++++ .../tests/suite/safety_check_downgrade.rs | 4 +++ codex-rs/core/tests/suite/shell_snapshot.rs | 4 +++ codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 +++ codex-rs/core/tests/suite/tool_parallelism.rs | 2 ++ codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 ++++++++++++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 ++++++++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/docs/protocol_v1.md | 2 +- codex-rs/protocol/src/protocol.rs | 5 +++ codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui_app_server/src/app.rs | 4 ++- codex-rs/tui_app_server/src/app_command.rs | 4 +++ 43 files changed, 198 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9b81025526..5e428362f2 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4550,6 +4550,7 @@ mod handlers { Op::UserTurn { cwd, approval_policy, + approvals_reviewer, sandbox_policy, model, effort, @@ -4575,7 +4576,7 @@ mod handlers { SessionSettingsUpdate { cwd: Some(cwd), approval_policy: Some(approval_policy), - approvals_reviewer: None, + approvals_reviewer, sandbox_policy: Some(sandbox_policy), windows_sandbox_level: None, collaboration_mode, diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 24470051f2..5d6769e0ea 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -3013,6 +3013,41 @@ fn op_kind_distinguishes_turn_ops() { ); } +#[tokio::test] +async fn user_turn_updates_approvals_reviewer() { + let (session, turn_context, _rx) = make_session_and_context_with_rx().await; + let config = session.get_config().await; + + handlers::user_input_or_turn( + &session, + "sub-1".to_string(), + Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + cwd: config.cwd.clone(), + approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: Some(crate::config::types::ApprovalsReviewer::GuardianSubagent), + sandbox_policy: config.permissions.sandbox_policy.get().clone(), + model: turn_context.model_info.slug.clone(), + effort: config.model_reasoning_effort, + summary: config.model_reasoning_summary, + service_tier: None, + final_output_json_schema: None, + collaboration_mode: None, + personality: config.personality, + }, + ) + .await; + + let state = session.state.lock().await; + assert_eq!( + state.session_configuration.approvals_reviewer, + crate::config::types::ApprovalsReviewer::GuardianSubagent + ); +} + #[tokio::test] async fn spawn_task_turn_span_inherits_dispatch_trace_context() { struct TraceCaptureTask { diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 729e172387..50bf2ed843 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -514,6 +514,7 @@ async fn run_review_on_session( items: params.prompt_items.clone(), cwd: params.parent_turn.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: params.model.clone(), effort: params.reasoning_effort, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 6df93bcd85..c61631e5bf 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -723,6 +723,7 @@ impl TestCodex { final_output_json_schema: None, cwd: self.config.cwd.clone(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index b113fc465c..d0cefcb616 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -310,6 +310,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -919,6 +920,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1000,6 +1002,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1151,6 +1154,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1215,6 +1219,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1287,6 +1292,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1359,6 +1365,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f77697d017..6ec05bedc2 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -558,6 +558,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 3ea30c5967..5da71f556b 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1310,6 +1310,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re }], cwd: config.cwd.clone(), approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: session_configured.model.clone(), effort: Some(ReasoningEffort::Low), @@ -1427,6 +1428,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() }], cwd: config.cwd.clone(), approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: session_configured.model, effort: None, diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index b9e4f05b36..fa8229ecc3 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2319,6 +2319,7 @@ text( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 81d0678cad..1517d9968c 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -178,6 +178,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { }], cwd: test.config.cwd.clone(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), model: test.session_configured.model.clone(), effort: None, @@ -293,6 +294,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu }], cwd: test.config.cwd.clone(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 2f4365a959..a54d740111 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1658,6 +1658,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { final_output_json_schema: None, cwd: resumed.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: resumed.session_configured.model.clone(), effort: None, @@ -1748,6 +1749,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: previous_model.to_string(), effort: None, @@ -1772,6 +1774,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: next_model.to_string(), effort: None, @@ -1882,6 +1885,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { final_output_json_schema: None, cwd: initial.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: previous_model.to_string(), effort: None, @@ -1930,6 +1934,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { final_output_json_schema: None, cwd: resumed.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: next_model.to_string(), effort: None, @@ -3132,6 +3137,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: previous_model.to_string(), effort: None, @@ -3156,6 +3162,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: next_model.to_string(), effort: None, diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 18be468020..fb055c970f 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -51,6 +51,7 @@ async fn submit_user_turn( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, @@ -131,6 +132,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 07b51c7629..1526fe11a2 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -122,6 +122,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -205,6 +206,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index d860545553..f949cd4b97 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -522,6 +522,7 @@ 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_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, @@ -598,6 +599,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, @@ -706,6 +708,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, @@ -892,6 +895,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, @@ -1005,6 +1009,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index f7dfd02daf..3b6f3e3f9c 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -80,6 +80,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { final_output_json_schema: Some(serde_json::from_str(SCHEMA)?), cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 2192bdffd9..663cf47488 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -61,6 +61,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 9902f0ee6c..a7c5ceb0d2 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -126,6 +126,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -162,6 +163,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: next_model.to_string(), effort: test.config.model_reasoning_effort, @@ -221,6 +223,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -257,6 +260,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: next_model.to_string(), effort: test.config.model_reasoning_effort, @@ -398,6 +402,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -418,6 +423,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -525,6 +531,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -545,6 +552,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -655,6 +663,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -675,6 +684,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -787,6 +797,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -815,6 +826,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -969,6 +981,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: large_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -1027,6 +1040,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: smaller_model_slug.to_string(), effort: test.config.model_reasoning_effort, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index a10fa7c262..49f635432f 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -121,6 +121,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -144,6 +145,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { final_output_json_schema: None, cwd: preturn_context_diff_cwd, approval_policy: AskForApproval::OnRequest, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -222,6 +224,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R final_output_json_schema: None, cwd: cwd_one.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -245,6 +248,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R final_output_json_schema: None, cwd: cwd_two, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -354,6 +358,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul final_output_json_schema: None, cwd: resume_override_cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: resumed.session_configured.model.clone(), effort: resumed.config.model_reasoning_effort, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 7cb7573347..3d5904bd7a 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -96,6 +96,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_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, 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 a479ebb80c..272c820caf 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -102,6 +102,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 9a495e7af4..ed4da07168 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -103,6 +103,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -153,6 +154,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -210,6 +212,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -273,6 +276,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -324,6 +328,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -361,6 +366,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -427,6 +433,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -464,6 +471,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -543,6 +551,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -580,6 +589,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -696,6 +706,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -814,6 +825,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -851,6 +863,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 14caaf8f0b..9be83e6bb0 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -703,6 +703,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }], cwd: new_cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: new_policy.clone(), model: "o3".to_string(), effort: Some(ReasoningEffort::High), @@ -815,6 +816,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a }], cwd: default_cwd.clone(), approval_policy: default_approval_policy, + approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), model: default_model.clone(), effort: default_effort, @@ -835,6 +837,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a }], cwd: default_cwd.clone(), approval_policy: default_approval_policy, + approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), model: default_model.clone(), effort: default_effort, @@ -939,6 +942,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu }], cwd: default_cwd.clone(), approval_policy: default_approval_policy, + approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), model: default_model, effort: default_effort, @@ -959,6 +963,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu }], cwd: default_cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: "o3".to_string(), effort: Some(ReasoningEffort::High), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 860d83fe9a..83d911533d 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -173,6 +173,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: requested_model.to_string(), effort: None, @@ -231,6 +232,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: requested_model.to_string(), effort: None, @@ -394,6 +396,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REMOTE_MODEL_SLUG.to_string(), effort: None, @@ -612,6 +615,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: model.to_string(), effort: None, diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index b1aaac65b4..7c16599bf0 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -193,6 +193,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index a01d6e0ab7..14506f4a41 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -145,6 +145,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 1bf759d270..8e30b37c21 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -138,6 +138,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -255,6 +256,7 @@ where final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 772674f79a..6cbf9521ba 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -129,6 +129,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -298,6 +299,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -501,6 +503,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_only_model_slug.to_string(), effort: None, @@ -615,6 +618,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -776,6 +780,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -1022,6 +1027,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index eabbdc9d03..51a88ef16a 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -45,6 +45,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, @@ -143,6 +144,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, @@ -228,6 +230,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, @@ -277,6 +280,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 68228a412e..55bb0ac284 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -164,6 +164,7 @@ async fn run_snapshot_command_with_options( final_output_json_schema: None, cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -254,6 +255,7 @@ async fn run_shell_command_snapshot_with_options( final_output_json_schema: None, cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -324,6 +326,7 @@ async fn run_tool_turn_on_harness( final_output_json_schema: None, cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -555,6 +558,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { final_output_json_schema: None, cwd: cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index b5fda12ae0..5a50e09cee 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -65,6 +65,7 @@ async fn submit_turn_with_policies( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 801e0dd6bb..388618b56a 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -74,6 +74,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 248ada02cc..2df92dbf95 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -395,6 +395,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index bb1da9e8b1..9594195a5b 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -85,6 +85,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -155,6 +156,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -235,6 +237,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -330,6 +333,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -433,6 +437,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index a177a6ee37..faff0b2e09 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -42,6 +42,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -358,6 +359,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index e7b25799ef..f10567de24 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -486,6 +486,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 7252d9a6b6..c07fd20e5e 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -192,6 +192,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { final_output_json_schema: None, cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -325,6 +326,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -407,6 +409,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -492,6 +495,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -589,6 +593,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -668,6 +673,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -748,6 +754,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -882,6 +889,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1023,6 +1031,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1187,6 +1196,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1289,6 +1299,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1411,6 +1422,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1505,6 +1517,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1590,6 +1603,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1725,6 +1739,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1897,6 +1912,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1978,6 +1994,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2070,6 +2087,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2178,6 +2196,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2317,6 +2336,7 @@ PY final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2435,6 +2455,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2535,6 +2556,7 @@ PY final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2634,6 +2656,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, // Important! sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, @@ -2743,6 +2766,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -2842,6 +2866,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2981,6 +3006,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index eb593c6fe3..801ff76274 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -176,6 +176,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: fixture.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index efc2e53324..732d6b29eb 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -170,6 +170,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -278,6 +279,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { final_output_json_schema: None, cwd: cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -418,6 +420,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -516,6 +519,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -607,6 +611,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -706,6 +711,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -816,6 +822,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -917,6 +924,7 @@ await codex.emitImage(out); final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1036,6 +1044,7 @@ console.log(out.type); final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1128,6 +1137,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1203,6 +1213,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1283,6 +1294,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1405,6 +1417,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: model_slug.to_string(), effort: None, @@ -1479,6 +1492,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 0090093c77..e7f33df148 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -156,6 +156,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 4d4e5c147c..9f238b40ee 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -65,7 +65,7 @@ Since only 1 `Task` can be run at a time, for parallel tasks it is recommended t For complete documentation of the `Op` and `EventMsg` variants, refer to [protocol.rs](../protocol/src/protocol.rs). Some example payload types: - `Op` - - `Op::UserTurn` – Any input from the user to kick off a `Turn` + - `Op::UserTurn` – Any input from the user to kick off a `Turn`, including full per-turn context such as cwd, model, sandbox, approval policy, and optional `approvals_reviewer` - `Op::UserInput` – Legacy form of user input - `Op::Interrupt` – Interrupts a running turn - `Op::ExecApproval` – Approve or deny code execution diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e6b1ae79dc..b5c96f4d05 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -255,6 +255,11 @@ pub enum Op { /// Policy to use for command approval. approval_policy: AskForApproval, + /// Reviewer to use for approval requests raised during this turn. + /// + /// When omitted, the session keeps the current setting + approvals_reviewer: Option, + /// Policy to use for tool calls such as `local_shell`. sandbox_policy: SandboxPolicy, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a553240170..ade9235b40 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5260,6 +5260,7 @@ impl ChatWidget { items, cwd: self.config.cwd.clone(), approval_policy: self.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: self.config.permissions.sandbox_policy.get().clone(), model: effective_mode.model().to_string(), effort: effective_mode.reasoning_effort(), diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 6de3a2a34a..bb36d83949 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -2010,6 +2010,7 @@ impl App { items, cwd, approval_policy, + approvals_reviewer, sandbox_policy, model, effort, @@ -2042,7 +2043,8 @@ impl App { items.to_vec(), cwd.clone(), approval_policy, - self.chat_widget.config_ref().approvals_reviewer, + approvals_reviewer + .unwrap_or(self.chat_widget.config_ref().approvals_reviewer), sandbox_policy.clone(), model.to_string(), effort, diff --git a/codex-rs/tui_app_server/src/app_command.rs b/codex-rs/tui_app_server/src/app_command.rs index ed89ad86fb..e01a250274 100644 --- a/codex-rs/tui_app_server/src/app_command.rs +++ b/codex-rs/tui_app_server/src/app_command.rs @@ -42,6 +42,7 @@ pub(crate) enum AppCommandView<'a> { items: &'a [UserInput], cwd: &'a PathBuf, approval_policy: AskForApproval, + approvals_reviewer: &'a Option, sandbox_policy: &'a SandboxPolicy, model: &'a str, effort: Option, @@ -159,6 +160,7 @@ impl AppCommand { items, cwd, approval_policy, + approvals_reviewer: None, sandbox_policy, model, effort, @@ -303,6 +305,7 @@ impl AppCommand { items, cwd, approval_policy, + approvals_reviewer, sandbox_policy, model, effort, @@ -315,6 +318,7 @@ impl AppCommand { items, cwd, approval_policy: *approval_policy, + approvals_reviewer, sandbox_policy, model, effort: *effort, From 0f34b14b4193ae39fc2c39bad38645b646d448ca Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Mon, 23 Mar 2026 15:36:23 -0700 Subject: [PATCH 29/38] [codex] Add rollback context duplication snapshot (#15562) ## What changed - adds a targeted snapshot test for rollback with contextual diffs in `codex_tests.rs` - snapshots the exact model-visible request input before the rolled-back turn and on the follow-up request after rollback - shows the duplicate developer and environment context pair appearing again before the follow-up user message ## Why Rollback currently rewinds the reference context baseline without rewinding the live session overrides. On the next turn, the same contextual diff is emitted again and duplicated in the request sent to the model. ## Impact - makes the regression visible in a canonical snapshot test - keeps the snapshot on the shared `context_snapshot` path without adding new formatting helpers - gives a direct repro for future fixes to rollback/context reconstruction --------- Co-authored-by: Codex --- .../core/tests/suite/compact_resume_fork.rs | 140 ++++++++++++++++++ ...lowup_turn_duplicates_context_updates.snap | 25 ++++ 2 files changed, 165 insertions(+) create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_duplicates_context_updates.snap diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index fafbced0b7..0b7852af0e 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -16,6 +16,9 @@ use codex_core::ThreadManager; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::config::Config; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::WarningEvent; @@ -502,6 +505,143 @@ async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Resu Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +/// Scenario: rolling back a turn that introduced persistent pre-turn context +/// diffs currently duplicates those context updates on the next request. +async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<()> { + if network_disabled() { + println!("Skipping test because network is disabled in this sandbox"); + return Ok(()); + } + + const MODEL: &str = "gpt-5.1-codex"; + const TURN_ONE_USER: &str = "turn 1 user"; + const TURN_TWO_USER: &str = "turn 2 user"; + const FOLLOWUP_USER: &str = "follow-up user"; + const ROLLED_BACK_DEV_INSTRUCTIONS: &str = "ROLLED_BACK_DEV_INSTRUCTIONS"; + const PRETURN_CONTEXT_DIFF_CWD: &str = "PRETURN_CONTEXT_DIFF_CWD"; + + let server = MockServer::start().await; + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "turn 1 assistant"), + ev_completed("r1"), + ]), + sse(vec![ + ev_assistant_message("m2", "turn 2 assistant"), + ev_completed("r2"), + ]), + sse(vec![ev_completed("r3")]), + ], + ) + .await; + + let (_home, config, _manager, conversation) = + start_test_conversation(&server, Some(MODEL)).await; + + user_turn(&conversation, TURN_ONE_USER).await; + + let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD); + std::fs::create_dir_all(&override_cwd)?; + conversation + .submit(Op::OverrideTurnContext { + cwd: Some(override_cwd), + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: MODEL.to_string(), + reasoning_effort: None, + developer_instructions: Some(ROLLED_BACK_DEV_INSTRUCTIONS.to_string()), + }, + }), + personality: None, + }) + .await?; + + user_turn(&conversation, TURN_TWO_USER).await; + + conversation + .submit(Op::ThreadRollback { num_turns: 1 }) + .await?; + let rollback_event = wait_for_event(&conversation, |ev| { + matches!(ev, EventMsg::ThreadRolledBack(_)) + }) + .await; + let EventMsg::ThreadRolledBack(rollback_event) = rollback_event else { + panic!("expected thread rolled back event"); + }; + assert_eq!(rollback_event.num_turns, 1); + + user_turn(&conversation, FOLLOWUP_USER).await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 3); + + assert_eq!( + requests[1] + .message_input_texts("developer") + .iter() + .filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS)) + .count(), + 1 + ); + assert_eq!( + requests[1] + .message_input_texts("user") + .iter() + .filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD)) + .count(), + 1 + ); + assert_eq!( + requests[2] + .message_input_texts("developer") + .iter() + .filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS)) + .count(), + 2 + ); + + let after_rollback_user_texts = requests[2].message_input_texts("user"); + assert_eq!( + after_rollback_user_texts + .iter() + .filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD)) + .count(), + 2 + ); + assert_eq!( + after_rollback_user_texts.last().map(String::as_str), + Some(FOLLOWUP_USER) + ); + + insta::assert_snapshot!( + "rollback_followup_turn_duplicates_context_updates", + context_snapshot::format_labeled_requests_snapshot( + "rollback currently duplicates pre-turn override context updates on the follow-up request", + &[ + ("rolled-back turn request", &requests[1]), + ("follow-up request after rollback", &requests[2]), + ], + &ContextSnapshotOptions::default() + .strip_capability_instructions() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }), + ) + ); + + Ok(()) +} + fn normalize_line_endings(value: &mut Value) { match value { Value::String(text) => { diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_duplicates_context_updates.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_duplicates_context_updates.snap new file mode 100644 index 0000000000..676421c373 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_duplicates_context_updates.snap @@ -0,0 +1,25 @@ +--- +source: core/tests/suite/compact_resume_fork.rs +expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback currently duplicates pre-turn override context updates on the follow-up request\",\n&[(\"rolled-back turn request\", &requests[1]),\n(\"follow-up request after rollback\", &requests[2]),],\n&ContextSnapshotOptions::default().strip_capability_instructions().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 96 }),)" +--- +Scenario: rollback currently duplicates pre-turn override context updates on the follow-up request + +## rolled-back turn request +00:message/developer: +01:message/user:> +02:message/user:turn 1 user +03:message/assistant:turn 1 assistant +04:message/developer:ROLLED_BACK_DEV_INSTRUCTIONS +05:message/user: +06:message/user:turn 2 user + +## follow-up request after rollback +00:message/developer: +01:message/user:> +02:message/user:turn 1 user +03:message/assistant:turn 1 assistant +04:message/developer:ROLLED_BACK_DEV_INSTRUCTIONS +05:message/user: +06:message/developer:ROLLED_BACK_DEV_INSTRUCTIONS +07:message/user: +08:message/user:follow-up user From 4605c653085ac1ea3a4c48e4d1727022bd942f68 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 23 Mar 2026 22:56:17 +0000 Subject: [PATCH 30/38] feat: custom watcher for multi-agent v2 (#15570) Custom watcher that sends an InterAgentCommunication on end of turn --- codex-rs/core/src/agent/control.rs | 36 +++ codex-rs/core/src/agent/control_tests.rs | 243 ++++++++++++++++++ .../src/codex/rollout_reconstruction_tests.rs | 16 +- .../src/tools/handlers/multi_agents_tests.rs | 74 +++--- 4 files changed, 327 insertions(+), 42 deletions(-) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 3bdcc2efd9..282e29c48a 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -266,6 +266,7 @@ impl AgentControl { new_thread.thread_id, notification_source, child_reference, + agent_metadata.agent_path.clone(), ); Ok(LiveAgent { @@ -437,6 +438,7 @@ impl AgentControl { resumed_thread.thread_id, Some(notification_source.clone()), child_reference, + agent_metadata.agent_path.clone(), ); self.persist_thread_spawn_edge_for_source( resumed_thread.thread.as_ref(), @@ -687,6 +689,7 @@ impl AgentControl { child_thread_id: ThreadId, session_source: Option, child_reference: String, + child_agent_path: Option, ) { let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, .. @@ -717,6 +720,39 @@ impl AgentControl { let Ok(state) = control.upgrade() else { return; }; + let child_thread = state.get_thread(child_thread_id).await.ok(); + if let Some(child_agent_path) = child_agent_path + && child_thread + .as_ref() + .map(|thread| thread.enabled(Feature::MultiAgentV2)) + .unwrap_or(true) + { + let AgentStatus::Completed(Some(content)) = &status else { + return; + }; + let Some((parent_path, _)) = child_agent_path.as_str().rsplit_once('/') else { + return; + }; + let Ok(parent_agent_path) = AgentPath::try_from(parent_path) else { + return; + }; + let Some(parent_thread_id) = control.state.agent_id_for_path(&parent_agent_path) + else { + return; + }; + let _ = control + .send_inter_agent_communication( + parent_thread_id, + InterAgentCommunication::new( + child_agent_path, + parent_agent_path, + Vec::new(), + content.clone(), + ), + ) + .await; + return; + } let Ok(parent_thread) = state.get_thread(parent_thread_id).await else { return; }; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 677bf5a2f3..2cf7398f7d 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -11,11 +11,13 @@ use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG; use assert_matches::assert_matches; use chrono::Utc; use codex_features::Feature; +use codex_protocol::AgentPath; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnAbortReason; @@ -125,6 +127,29 @@ fn history_contains_text(history_items: &[ResponseItem], needle: &str) -> bool { }) } +fn history_contains_assistant_inter_agent_communication( + history_items: &[ResponseItem], + expected: &InterAgentCommunication, +) -> bool { + history_items.iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "assistant" { + return false; + } + content.iter().any(|content_item| match content_item { + ContentItem::OutputText { text } => { + serde_json::from_str::(text) + .ok() + .as_ref() + == Some(expected) + } + ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false, + }) + }) +} + async fn wait_for_subagent_notification(parent_thread: &Arc) -> bool { let wait = async { loop { @@ -937,6 +962,223 @@ async fn spawn_child_completion_notifies_parent_history() { assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); } +#[tokio::test] +async fn multi_agent_v2_completion_sends_inter_agent_message_to_direct_parent() { + let harness = AgentControlHarness::new().await; + let (root_thread_id, _) = harness.start_thread().await; + let mut config = harness.config.clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + let worker_path = AgentPath::root().join("worker_a").expect("worker path"); + let worker_thread_id = harness + .control + .spawn_agent( + config.clone(), + text_input("hello worker"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root_thread_id, + depth: 1, + agent_path: Some(worker_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("worker spawn should succeed"); + let tester_path = worker_path.join("tester").expect("tester path"); + let tester_thread_id = harness + .control + .spawn_agent( + config, + text_input("hello tester"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: Some(tester_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("tester spawn should succeed"); + + let tester_thread = harness + .manager + .get_thread(tester_thread_id) + .await + .expect("tester thread should exist"); + let tester_turn = tester_thread.codex.session.new_default_turn().await; + tester_thread + .codex + .session + .send_event( + tester_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: tester_turn.sub_id.clone(), + last_agent_message: Some("done".to_string()), + }), + ) + .await; + + timeout(Duration::from_secs(2), async { + loop { + let delivered = harness + .manager + .captured_ops() + .into_iter() + .any(|(thread_id, op)| { + thread_id == worker_thread_id + && matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == tester_path + && communication.recipient == worker_path + && communication.other_recipients.is_empty() + && communication.content == "done" + ) + }); + if delivered { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("completion watcher should send inter-agent communication"); + + let worker_thread = harness + .manager + .get_thread(worker_thread_id) + .await + .expect("worker thread should exist"); + let expected_message = InterAgentCommunication::new( + tester_path.clone(), + worker_path.clone(), + Vec::new(), + "done".to_string(), + ); + timeout(Duration::from_secs(2), async { + loop { + let history_items = worker_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + if history_contains_assistant_inter_agent_communication( + &history_items, + &expected_message, + ) && !has_subagent_notification(&history_items) + { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("worker should record assistant inter-agent message"); +} + +#[tokio::test] +async fn multi_agent_v2_completion_ignores_dead_direct_parent() { + let harness = AgentControlHarness::new().await; + let (root_thread_id, root_thread) = harness.start_thread().await; + let mut config = harness.config.clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + let worker_path = AgentPath::root().join("worker_a").expect("worker path"); + let worker_thread_id = harness + .control + .spawn_agent( + config.clone(), + text_input("hello worker"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root_thread_id, + depth: 1, + agent_path: Some(worker_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("worker spawn should succeed"); + let tester_path = worker_path.join("tester").expect("tester path"); + let tester_thread_id = harness + .control + .spawn_agent( + config, + text_input("hello tester"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: Some(tester_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("tester spawn should succeed"); + harness + .control + .shutdown_live_agent(worker_thread_id) + .await + .expect("worker shutdown should succeed"); + + let tester_thread = harness + .manager + .get_thread(tester_thread_id) + .await + .expect("tester thread should exist"); + let tester_turn = tester_thread.codex.session.new_default_turn().await; + tester_thread + .codex + .session + .send_event( + tester_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: tester_turn.sub_id.clone(), + last_agent_message: Some("done".to_string()), + }), + ) + .await; + + sleep(Duration::from_millis(100)).await; + + assert!( + !harness + .manager + .captured_ops() + .into_iter() + .any(|(thread_id, op)| { + thread_id == worker_thread_id + && matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == tester_path + && communication.recipient == worker_path + && communication.content == "done" + ) + }) + ); + + let root_history_items = root_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert!(!history_contains_assistant_inter_agent_communication( + &root_history_items, + &InterAgentCommunication::new( + tester_path, + AgentPath::root(), + Vec::new(), + "done".to_string(), + ) + )); + assert!(!has_subagent_notification(&root_history_items)); +} + #[tokio::test] async fn completion_watcher_notifies_parent_when_child_is_missing() { let harness = AgentControlHarness::new().await; @@ -953,6 +1195,7 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() { agent_role: Some("explorer".to_string()), })), child_thread_id.to_string(), + None, ); assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 96423754db..33fb5f9af7 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -36,19 +36,17 @@ fn assistant_message(text: &str) -> ResponseItem { } fn inter_agent_assistant_message(text: &str) -> ResponseItem { + let communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root().join("worker").unwrap(), + Vec::new(), + text.to_string(), + ); ResponseItem::Message { id: None, role: "assistant".to_string(), content: vec![ContentItem::OutputText { - text: serde_json::to_string(&InterAgentCommunication::new( - AgentPath::root(), - AgentPath::root() - .join("worker") - .expect("worker path should be valid"), - Vec::new(), - text.to_string(), - )) - .expect("inter-agent communication should serialize"), + text: serde_json::to_string(&communication).unwrap(), }], end_turn: None, phase: None, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index f74ebf0577..b780986d98 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -80,14 +80,31 @@ fn thread_manager() -> ThreadManager { ) } +fn history_contains_inter_agent_communication( + history_items: &[ResponseItem], + expected: &InterAgentCommunication, +) -> bool { + history_items.iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "assistant" { + return false; + } + content.iter().any(|content_item| match content_item { + ContentItem::OutputText { text } => { + serde_json::from_str::(text) + .ok() + .as_ref() + == Some(expected) + } + ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false, + }) + }) +} + fn inter_agent_message_text(recipient: &str, content: &str) -> String { - serde_json::to_string(&InterAgentCommunication::new( - AgentPath::root(), - AgentPath::try_from(recipient).expect("recipient path should be valid"), - Vec::new(), - content.to_string(), - )) - .expect("inter-agent communication should serialize") + format!("author: /root\nrecipient: {recipient}\nother_recipients: []\nContent: {content}") } #[derive(Clone, Copy)] @@ -401,7 +418,12 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( .get_thread(child_thread_id) .await .expect("child thread should exist"); - let expected_message = inter_agent_message_text("/root/test_process", "continue"); + let expected_communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/test_process").expect("agent path"), + Vec::new(), + "continue".to_string(), + ); timeout(Duration::from_secs(2), async { loop { let history_items = child_thread @@ -411,18 +433,8 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( .await .raw_items() .to_vec(); - let recorded = history_items.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "assistant" - && content.iter().any(|content_item| matches!( - content_item, - ContentItem::OutputText { text } - if text == &expected_message - )) - ) - }); + let recorded = + history_contains_inter_agent_communication(&history_items, &expected_communication); let saw_user_message = history_items.iter().any(|item| { matches!( item, @@ -664,7 +676,6 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( )) ))); - let expected_message = inter_agent_message_text("/root/worker", "continue"); timeout(Duration::from_secs(5), async { loop { let history_items = thread @@ -674,18 +685,15 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( .await .raw_items() .to_vec(); - let saw_envelope = history_items.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "assistant" - && content.iter().any(|content_item| matches!( - content_item, - ContentItem::OutputText { text } - if text == &expected_message - )) - ) - }); + let saw_envelope = history_contains_inter_agent_communication( + &history_items, + &InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "continue".to_string(), + ), + ); let saw_user_message = history_items.iter().any(|item| { matches!( item, From 0b5ba25b467869c35891c9c2b38178a5d13011bd Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 23 Mar 2026 22:57:54 +0000 Subject: [PATCH 31/38] feat: custom watcher for multi-agent v2 (#15575) From 527244910fb851cea6147334dbc08f8fbce4cb9d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 23 Mar 2026 23:27:55 +0000 Subject: [PATCH 32/38] feat: custom watcher for multi-agent v2 (#15576) The new wait tool just returns `Wait timed out.` or `Wait completed.`. The actual content is done through the notification watcher --- .../src/tools/handlers/multi_agents_tests.rs | 71 ++++++++++++++++--- .../tools/handlers/multi_agents_v2/wait.rs | 38 +++++----- codex-rs/core/src/tools/spec.rs | 65 +++++++++++------ codex-rs/core/src/tools/spec_tests.rs | 6 +- 4 files changed, 123 insertions(+), 57 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index b780986d98..873b55d660 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -9,12 +9,14 @@ use crate::config::types::ShellEnvironmentPolicy; use crate::function_tool::FunctionCallError; use crate::protocol::AgentStatus; use crate::protocol::AskForApproval; +use crate::protocol::EventMsg; use crate::protocol::FileSystemSandboxPolicy; use crate::protocol::NetworkSandboxPolicy; use crate::protocol::Op; use crate::protocol::SandboxPolicy; use crate::protocol::SessionSource; use crate::protocol::SubAgentSource; +use crate::protocol::TurnCompleteEvent; use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; @@ -1414,7 +1416,7 @@ async fn multi_agent_v2_wait_agent_accepts_targets_argument() { assert_eq!( result, crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { - status: HashMap::from([(target, AgentStatus::NotFound)]), + message: "Wait completed.".to_string(), timed_out: false, } ); @@ -1582,12 +1584,7 @@ async fn wait_agent_returns_final_status_without_timeout() { } #[tokio::test] -async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { - #[derive(Debug, Deserialize)] - struct SpawnAgentResult { - task_name: String, - } - +async fn multi_agent_v2_wait_agent_returns_summary_for_named_targets() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -1617,9 +1614,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { )) .await .expect("spawn_agent should succeed"); - let (content, _) = expect_text_output(spawn_output); - let spawn_result: SpawnAgentResult = - serde_json::from_str(&content).expect("spawn result should parse"); + let _ = expect_text_output(spawn_output); let agent_id = session .services @@ -1667,13 +1662,67 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { assert_eq!( result, crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { - status: HashMap::from([(spawn_result.task_name, AgentStatus::Shutdown)]), + message: "Wait completed.".to_string(), timed_out: false, } ); assert_eq!(success, None); } +#[tokio::test] +async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config.clone()); + + let thread = manager.start_thread(config).await.expect("start thread"); + let agent_id = thread.thread_id; + let child_turn = thread.thread.codex.session.new_default_turn().await; + thread + .thread + .codex + .session + .send_event( + child_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: child_turn.sub_id.clone(), + last_agent_message: Some("sensitive child output".to_string()), + }), + ) + .await; + + let output = WaitAgentHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "wait_agent", + function_payload(json!({ + "targets": [agent_id.to_string()], + "timeout_ms": 1000 + })), + )) + .await + .expect("wait_agent should succeed"); + let (content, success) = expect_text_output(output); + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = + serde_json::from_str(&content).expect("wait_agent result should be json"); + assert_eq!( + result, + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait completed.".to_string(), + timed_out: false, + } + ); + assert!(!content.contains("sensitive child output")); + assert_eq!(success, None); +} + #[tokio::test] async fn close_agent_submits_shutdown_and_returns_previous_status() { let (mut session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index e4bc7d3f4a..1b1dc90e42 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -35,21 +35,12 @@ impl ToolHandler for Handler { let args: WaitArgs = parse_arguments(&arguments)?; let receiver_thread_ids = resolve_agent_targets(&session, &turn, args.targets).await?; let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len()); - let mut target_by_thread_id = HashMap::with_capacity(receiver_thread_ids.len()); for receiver_thread_id in &receiver_thread_ids { let agent_metadata = session .services .agent_control .get_agent_metadata(*receiver_thread_id) .unwrap_or_default(); - target_by_thread_id.insert( - *receiver_thread_id, - agent_metadata - .agent_path - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| receiver_thread_id.to_string()), - ); receiver_agents.push(CollabAgentRef { thread_id: *receiver_thread_id, agent_nickname: agent_metadata.agent_nickname, @@ -152,18 +143,7 @@ impl ToolHandler for Handler { let timed_out = statuses.is_empty(); let statuses_by_id = statuses.clone().into_iter().collect::>(); let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents); - let result = WaitAgentResult { - status: statuses - .into_iter() - .filter_map(|(thread_id, status)| { - target_by_thread_id - .get(&thread_id) - .cloned() - .map(|target| (target, status)) - }) - .collect(), - timed_out, - }; + let result = WaitAgentResult::from_timed_out(timed_out); session .send_event( @@ -191,10 +171,24 @@ struct WaitArgs { #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] pub(crate) struct WaitAgentResult { - pub(crate) status: HashMap, + pub(crate) message: String, pub(crate) timed_out: bool, } +impl WaitAgentResult { + fn from_timed_out(timed_out: bool) -> Self { + let message = if timed_out { + "Wait timed out." + } else { + "Wait completed." + }; + Self { + message: message.to_string(), + timed_out, + } + } +} + impl ToolOutput for WaitAgentResult { fn log_preview(&self) -> String { tool_output_json_text(self, "wait_agent") diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index d8419e1f58..b115e572e6 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -178,23 +178,41 @@ fn resume_agent_output_schema() -> JsonValue { }) } -fn wait_output_schema() -> JsonValue { - json!({ - "type": "object", - "properties": { - "status": { - "type": "object", - "description": "Final statuses keyed by canonical task name when available, otherwise by agent id.", - "additionalProperties": agent_status_output_schema() +fn wait_output_schema(multi_agent_v2: bool) -> JsonValue { + if multi_agent_v2 { + json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Brief wait summary without the agent's final content." + }, + "timed_out": { + "type": "boolean", + "description": "Whether the wait call returned due to timeout before any agent reached a final status." + } }, - "timed_out": { - "type": "boolean", - "description": "Whether the wait call returned due to timeout before any agent reached a final status." - } - }, - "required": ["status", "timed_out"], - "additionalProperties": false - }) + "required": ["message", "timed_out"], + "additionalProperties": false + }) + } else { + json!({ + "type": "object", + "properties": { + "status": { + "type": "object", + "description": "Final statuses keyed by canonical task name when available, otherwise by agent id.", + "additionalProperties": agent_status_output_schema() + }, + "timed_out": { + "type": "boolean", + "description": "Whether the wait call returned due to timeout before any agent reached a final status." + } + }, + "required": ["status", "timed_out"], + "additionalProperties": false + }) + } } fn close_agent_output_schema() -> JsonValue { @@ -1422,7 +1440,7 @@ fn create_resume_agent_tool() -> ToolSpec { }) } -fn create_wait_agent_tool() -> ToolSpec { +fn create_wait_agent_tool(multi_agent_v2: bool) -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( "targets".to_string(), @@ -1445,8 +1463,13 @@ fn create_wait_agent_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "wait_agent".to_string(), - description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status." - .to_string(), + description: if multi_agent_v2 { + "Wait for agents to reach a final status. Returns a brief wait summary instead of the agent's final content. Returns a timeout summary when no agent reaches a final status before the deadline." + .to_string() + } else { + "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status." + .to_string() + }, strict: false, defer_loading: None, parameters: JsonSchema::Object { @@ -1454,7 +1477,7 @@ fn create_wait_agent_tool() -> ToolSpec { required: Some(vec!["targets".to_string()]), additional_properties: Some(false.into()), }, - output_schema: Some(wait_output_schema()), + output_schema: Some(wait_output_schema(multi_agent_v2)), }) } @@ -3006,7 +3029,7 @@ pub(crate) fn build_specs_with_discoverable_tools( } push_tool_spec( &mut builder, - create_wait_agent_tool(), + create_wait_agent_tool(config.multi_agent_v2), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 1cb6bb1664..b33748dd35 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -469,7 +469,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { create_view_image_tool(config.can_request_original_image_detail), create_spawn_agent_tool(&config), create_send_input_tool(), - create_wait_agent_tool(), + create_wait_agent_tool(config.multi_agent_v2), create_close_agent_tool(), ] { expected.insert(tool_name(&spec).to_string(), spec); @@ -607,8 +607,8 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { .as_ref() .expect("wait_agent should define output schema"); assert_eq!( - output_schema["properties"]["status"]["description"], - json!("Final statuses keyed by canonical task name when available, otherwise by agent id.") + output_schema["properties"]["message"]["description"], + json!("Brief wait summary without the agent's final content.") ); assert_lacks_tool_name(&tools, "resume_agent"); } From 84fb180eeb9ef8bbef9986a715925c10d6e15e37 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 23 Mar 2026 18:59:04 -0700 Subject: [PATCH 33/38] fix: build PATH env var using OsString instead of String (#15360) --- codex-rs/arg0/src/lib.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index d8dbaaa30d..e210783a35 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -309,14 +309,16 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { - format!("{path_element}{PATH_SEPARATOR}{existing_path}") - } - Err(_) => { - format!("{path_element}") + let updated_path_env_var = match std::env::var_os("PATH") { + Some(existing_path) => { + let mut path_env_var = + std::ffi::OsString::with_capacity(path.as_os_str().len() + 1 + existing_path.len()); + path_env_var.push(path); + path_env_var.push(PATH_SEPARATOR); + path_env_var.push(existing_path); + path_env_var } + None => path.as_os_str().to_owned(), }; unsafe { From f547b79bd0003fe2e12b7c9f9449b303f37fcc27 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Mon, 23 Mar 2026 19:05:42 -0700 Subject: [PATCH 34/38] Add fork snapshot modes (#15239) ## Summary - add `ForkSnapshotMode` to `ThreadManager::fork_thread` so callers can request either a committed snapshot or an interrupted snapshot - share the model-visible `` history marker between the live interrupt path and interrupted forks - update the small set of direct fork callsites to pass `ForkSnapshotMode::Committed` Note: this enables /btw to work similarly as Esc to interrupt (hopefully somewhat in distribution) --------- Co-authored-by: Codex --- .../src/protocol/thread_history.rs | 22 + codex-rs/app-server/README.md | 4 +- .../app-server/src/codex_message_processor.rs | 5 +- .../app-server/tests/suite/v2/thread_fork.rs | 2 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout/truncation.rs | 8 +- codex-rs/core/src/rollout/truncation_tests.rs | 6 +- codex-rs/core/src/tasks/mod.rs | 29 +- codex-rs/core/src/thread_manager.rs | 198 ++++++- codex-rs/core/src/thread_manager_tests.rs | 541 +++++++++++++++++- .../core/tests/suite/compact_resume_fork.rs | 24 +- codex-rs/core/tests/suite/fork_thread.rs | 17 +- .../core/tests/suite/permissions_messages.rs | 9 +- codex-rs/core/tests/suite/shell_snapshot.rs | 4 +- codex-rs/tui/src/app.rs | 5 +- 15 files changed, 823 insertions(+), 52 deletions(-) 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 11dfe29769..48fa56d687 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -74,6 +74,8 @@ pub struct ThreadHistoryBuilder { turns: Vec, current_turn: Option, next_item_index: i64, + current_rollout_index: usize, + next_rollout_index: usize, } impl Default for ThreadHistoryBuilder { @@ -88,6 +90,8 @@ impl ThreadHistoryBuilder { turns: Vec::new(), current_turn: None, next_item_index: 1, + current_rollout_index: 0, + next_rollout_index: 0, } } @@ -111,6 +115,19 @@ impl ThreadHistoryBuilder { self.current_turn.is_some() } + pub fn active_turn_id_if_explicit(&self) -> Option { + self.current_turn + .as_ref() + .filter(|turn| turn.opened_explicitly) + .map(|turn| turn.id.clone()) + } + + pub fn active_turn_start_index(&self) -> Option { + self.current_turn + .as_ref() + .map(|turn| turn.rollout_start_index) + } + /// Shared reducer for persisted rollout replay and in-memory current-turn /// tracking used by running thread resume/rejoin. /// @@ -182,6 +199,8 @@ impl ThreadHistoryBuilder { } pub fn handle_rollout_item(&mut self, item: &RolloutItem) { + self.current_rollout_index = self.next_rollout_index; + self.next_rollout_index += 1; match item { RolloutItem::EventMsg(event) => self.handle_event(event), RolloutItem::Compacted(payload) => self.handle_compacted(payload), @@ -974,6 +993,7 @@ impl ThreadHistoryBuilder { status: TurnStatus::Completed, opened_explicitly: false, saw_compaction: false, + rollout_start_index: self.current_rollout_index, } } @@ -1137,6 +1157,8 @@ struct PendingTurn { /// True when this turn includes a persisted `RolloutItem::Compacted`, which /// should keep the turn from being dropped even without normal items. saw_compaction: bool, + /// Index of the rollout item that opened this turn during replay. + rollout_start_index: usize, } impl PendingTurn { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ace7f2bd43..a536e5c68b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -125,7 +125,7 @@ Example with notification opt-out: - `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. -- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. +- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -240,7 +240,7 @@ Example: { "id": 11, "result": { "thread": { "id": "thr_123", … } } } ``` -To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. Pass `ephemeral: true` when the fork should stay in-memory only: +To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: ```json { "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 947a49ff2f..37ff0ca4f2 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -183,6 +183,7 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::CodexThread; use codex_core::Cursor as RolloutCursor; +use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::RolloutRecorder; use codex_core::SessionMeta; @@ -4039,7 +4040,7 @@ impl CodexMessageProcessor { } = match self .thread_manager .fork_thread( - usize::MAX, + ForkSnapshot::Interrupted, config, rollout_path.clone(), persist_extended_history, @@ -6508,7 +6509,7 @@ impl CodexMessageProcessor { } = self .thread_manager .fork_thread( - usize::MAX, + ForkSnapshot::Interrupted, config, rollout_path, /*persist_extended_history*/ false, 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 1b40fadc67..a81c87bd4e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -123,7 +123,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { "expected forked thread to include one turn" ); let turn = &thread.turns[0]; - assert_eq!(turn.status, TurnStatus::Completed); + assert_eq!(turn.status, TurnStatus::Interrupted); assert_eq!(turn.items.len(), 1, "expected user message item"); match &turn.items[0] { ThreadItem::UserMessage { content, .. } => { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 29436a0d7f..e51bf45076 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -97,6 +97,7 @@ mod seatbelt_permissions; mod thread_manager; pub mod web_search; pub mod windows_sandbox_read_grants; +pub use thread_manager::ForkSnapshot; pub use thread_manager::NewThread; pub use thread_manager::ThreadManager; #[deprecated(note = "use ThreadManager")] diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/rollout/truncation.rs index 490bf42b97..fee84974fe 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/rollout/truncation.rs @@ -46,8 +46,8 @@ pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!( + "{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n{TURN_ABORTED_CLOSE_TAG}" + ), + }], + end_turn: None, + phase: None, + } +} + fn emit_turn_network_proxy_metric( session_telemetry: &SessionTelemetry, network_proxy_active: bool, @@ -457,17 +474,7 @@ impl Session { if reason == TurnAbortReason::Interrupted { self.cleanup_after_interrupt(&task.turn_context).await; - let marker = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!( - "{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n" - ), - }], - end_turn: None, - phase: None, - }; + let marker = interrupted_turn_history_marker(); self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref()) .await; self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)]) diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 185a32a70f..642def5559 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -24,6 +24,9 @@ use crate::rollout::RolloutRecorder; use crate::rollout::truncation; use crate::shell_snapshot::ShellSnapshot; use crate::skills::SkillsManager; +use crate::tasks::interrupted_turn_history_marker; +use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::TurnStatus; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; #[cfg(test)] @@ -34,6 +37,8 @@ use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::W3cTraceContext; use futures::StreamExt; use futures::stream::FuturesUnordered; @@ -126,6 +131,45 @@ pub struct NewThread { pub session_configured: SessionConfiguredEvent, } +// TODO(ccunningham): Add an explicit non-interrupting live-turn snapshot once +// core can represent sampling boundaries directly instead of relying on +// whichever items happened to be persisted mid-turn. +// +// Two likely future variants: +// - `TruncateToLastSamplingBoundary` for callers that want a coherent fork from +// the last stable model boundary without synthesizing an interrupt. +// - `WaitUntilNextSamplingBoundary` (or similar) for callers that prefer to +// fork after the next sampling boundary rather than interrupting immediately. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForkSnapshot { + /// Fork a committed prefix ending strictly before the nth user message. + /// + /// When `n` is within range, this cuts before that 0-based user-message + /// boundary. When `n` is out of range and the source thread is currently + /// mid-turn, this instead cuts before the active turn's opening boundary + /// so the fork drops the unfinished turn suffix. When `n` is out of range + /// and the source thread is already at a turn boundary, this returns the + /// full committed history unchanged. + TruncateBeforeNthUserMessage(usize), + + /// Fork the current persisted history as if the source thread had been + /// interrupted now. + /// + /// If the persisted snapshot ends mid-turn, this appends the same + /// `` marker produced by a real interrupt. If the snapshot is + /// already at a turn boundary, this returns the current persisted history + /// unchanged. + Interrupted, +} + +/// Preserve legacy `fork_thread(usize, ...)` callsites by mapping them to the +/// existing truncate-before-nth-user-message snapshot mode. +impl From for ForkSnapshot { + fn from(value: usize) -> Self { + Self::TruncateBeforeNthUserMessage(value) + } +} + #[derive(Debug, Default, PartialEq, Eq)] pub struct ThreadShutdownReport { pub completed: Vec, @@ -541,20 +585,41 @@ impl ThreadManager { report } - /// Fork an existing thread by taking messages up to the given position (not including - /// the message at the given position) and starting a new thread with identical - /// configuration (unless overridden by the caller's `config`). The new thread will have - /// a fresh id. Pass `usize::MAX` to keep the full rollout history. - pub async fn fork_thread( + /// Fork an existing thread by snapshotting rollout history according to + /// `snapshot` and starting a new thread with identical configuration + /// (unless overridden by the caller's `config`). The new thread will have + /// a fresh id. + pub async fn fork_thread( &self, - nth_user_message: usize, + snapshot: S, config: Config, path: PathBuf, persist_extended_history: bool, parent_trace: Option, - ) -> CodexResult { + ) -> CodexResult + where + S: Into, + { + let snapshot = snapshot.into(); let history = RolloutRecorder::get_rollout_history(&path).await?; - let history = truncate_before_nth_user_message(history, nth_user_message); + let snapshot_state = snapshot_turn_state(&history); + let history = match snapshot { + ForkSnapshot::TruncateBeforeNthUserMessage(nth_user_message) => { + truncate_before_nth_user_message(history, nth_user_message, &snapshot_state) + } + ForkSnapshot::Interrupted => { + let history = match history { + InitialHistory::New => InitialHistory::New, + InitialHistory::Forked(history) => InitialHistory::Forked(history), + InitialHistory::Resumed(resumed) => InitialHistory::Forked(resumed.history), + }; + if snapshot_state.ends_mid_turn { + append_interrupted_boundary(history, snapshot_state.active_turn_id) + } else { + history + } + } + }; Box::pin(self.state.spawn_thread( config, history, @@ -838,11 +903,31 @@ impl ThreadManagerState { } } -/// Return a prefix of `items` obtained by cutting strictly before the nth user message -/// (0-based) and all items that follow it. -fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> InitialHistory { +/// Return a fork snapshot cut strictly before the nth user message (0-based). +/// +/// Out-of-range values keep the full committed history at a turn boundary, but +/// when the source thread is currently mid-turn they fall back to cutting +/// before the active turn's opening boundary so the fork omits the unfinished +/// suffix entirely. +fn truncate_before_nth_user_message( + history: InitialHistory, + n: usize, + snapshot_state: &SnapshotTurnState, +) -> InitialHistory { let items: Vec = history.get_rollout_items(); - let rolled = truncation::truncate_rollout_before_nth_user_message_from_start(&items, n); + let user_positions = truncation::user_message_positions_in_rollout(&items); + let rolled = if snapshot_state.ends_mid_turn && n >= user_positions.len() { + if let Some(cut_idx) = snapshot_state + .active_turn_start_index + .or_else(|| user_positions.last().copied()) + { + items[..cut_idx].to_vec() + } else { + items + } + } else { + truncation::truncate_rollout_before_nth_user_message_from_start(&items, n) + }; if rolled.is_empty() { InitialHistory::New @@ -851,6 +936,95 @@ fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> Initia } } +#[derive(Debug, Eq, PartialEq)] +struct SnapshotTurnState { + ends_mid_turn: bool, + active_turn_id: Option, + active_turn_start_index: Option, +} + +fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState { + let rollout_items = history.get_rollout_items(); + let mut builder = ThreadHistoryBuilder::new(); + for item in &rollout_items { + builder.handle_rollout_item(item); + } + let active_turn_id = builder.active_turn_id_if_explicit(); + if builder.has_active_turn() && active_turn_id.is_some() { + let active_turn_snapshot = builder.active_turn_snapshot(); + if active_turn_snapshot + .as_ref() + .is_some_and(|turn| turn.status != TurnStatus::InProgress) + { + return SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }; + } + + return SnapshotTurnState { + ends_mid_turn: true, + active_turn_id, + active_turn_start_index: builder.active_turn_start_index(), + }; + } + + let Some(last_user_position) = truncation::user_message_positions_in_rollout(&rollout_items) + .last() + .copied() + else { + return SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }; + }; + + // Synthetic fork/resume histories can contain user/assistant response items + // without explicit turn lifecycle events. If the persisted snapshot has no + // terminating boundary after its last user message, treat it as mid-turn. + SnapshotTurnState { + ends_mid_turn: !rollout_items[last_user_position + 1..].iter().any(|item| { + matches!( + item, + RolloutItem::EventMsg(EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_)) + ) + }), + active_turn_id: None, + active_turn_start_index: None, + } +} + +/// Append the same persisted interrupt boundary used by the live interrupt path +/// to an existing fork snapshot after the source thread has been confirmed to +/// be mid-turn. +fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) -> InitialHistory { + let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason: TurnAbortReason::Interrupted, + })); + + match history { + InitialHistory::New => InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(interrupted_turn_history_marker()), + aborted_event, + ]), + InitialHistory::Forked(mut history) => { + history.push(RolloutItem::ResponseItem(interrupted_turn_history_marker())); + history.push(aborted_event); + InitialHistory::Forked(history) + } + InitialHistory::Resumed(mut resumed) => { + resumed + .history + .push(RolloutItem::ResponseItem(interrupted_turn_history_marker())); + resumed.history.push(aborted_event); + InitialHistory::Forked(resumed.history) + } + } +} + #[cfg(test)] #[path = "thread_manager_tests.rs"] mod tests; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index e69e88fe73..40e7439ccc 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -3,11 +3,15 @@ use crate::codex::make_session_and_context; use crate::config::test_config; use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; use crate::models_manager::manager::RefreshStrategy; -use assert_matches::assert_matches; +use crate::rollout::RolloutRecorder; +use crate::tasks::interrupted_turn_history_marker; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::protocol::UserMessageEvent; use core_test_support::responses::mount_models_once; use pretty_assertions::assert_eq; use std::time::Duration; @@ -38,7 +42,7 @@ fn assistant_msg(text: &str) -> ResponseItem { } #[test] -fn drops_from_last_user_only() { +fn truncates_before_requested_user_message() { let items = [ user_msg("u1"), assistant_msg("a1"), @@ -68,7 +72,15 @@ fn drops_from_last_user_only() { .cloned() .map(RolloutItem::ResponseItem) .collect(); - let truncated = truncate_before_nth_user_message(InitialHistory::Forked(initial), 1); + let truncated = truncate_before_nth_user_message( + InitialHistory::Forked(initial), + 1, + &SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); let got_items = truncated.get_rollout_items(); let expected_items = vec![ RolloutItem::ResponseItem(items[0].clone()), @@ -85,8 +97,99 @@ fn drops_from_last_user_only() { .cloned() .map(RolloutItem::ResponseItem) .collect(); - let truncated2 = truncate_before_nth_user_message(InitialHistory::Forked(initial2), 2); - assert_matches!(truncated2, InitialHistory::New); + let truncated2 = truncate_before_nth_user_message( + InitialHistory::Forked(initial2.clone()), + 2, + &SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); + assert_eq!( + serde_json::to_value(truncated2.get_rollout_items()).unwrap(), + serde_json::to_value(initial2).unwrap() + ); +} + +#[test] +fn out_of_range_truncation_drops_only_unfinished_suffix_mid_turn() { + let items = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::ResponseItem(user_msg("u2")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]; + + let truncated = truncate_before_nth_user_message( + InitialHistory::Forked(items.clone()), + usize::MAX, + &SnapshotTurnState { + ends_mid_turn: true, + active_turn_id: None, + active_turn_start_index: None, + }, + ); + + assert_eq!( + serde_json::to_value(truncated.get_rollout_items()).unwrap(), + serde_json::to_value(items[..2].to_vec()).unwrap() + ); +} + +#[test] +fn fork_thread_accepts_legacy_usize_snapshot_argument() { + fn assert_legacy_snapshot_callsite( + manager: &ThreadManager, + config: Config, + path: std::path::PathBuf, + ) { + let _future = manager.fork_thread( + usize::MAX, + config, + path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ); + } + + let _: fn(&ThreadManager, Config, std::path::PathBuf) = assert_legacy_snapshot_callsite; +} + +#[test] +fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { + let items = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-2".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::ResponseItem(user_msg("u2")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]; + + let snapshot_state = snapshot_turn_state(&InitialHistory::Forked(items.clone())); + assert_eq!( + snapshot_state, + SnapshotTurnState { + ends_mid_turn: true, + active_turn_id: Some("turn-2".to_string()), + active_turn_start_index: Some(2), + }, + ); + + let truncated = truncate_before_nth_user_message( + InitialHistory::Forked(items.clone()), + usize::MAX, + &snapshot_state, + ); + + assert_eq!( + serde_json::to_value(truncated.get_rollout_items()).unwrap(), + serde_json::to_value(items[..2].to_vec()).unwrap() + ); } #[tokio::test] @@ -104,7 +207,15 @@ async fn ignores_session_prefix_messages_when_truncating() { .map(RolloutItem::ResponseItem) .collect(); - let truncated = truncate_before_nth_user_message(InitialHistory::Forked(rollout_items), 1); + let truncated = truncate_before_nth_user_message( + InitialHistory::Forked(rollout_items), + 1, + &SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); let got_items = truncated.get_rollout_items(); let expected: Vec = vec![ @@ -185,3 +296,421 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { let _ = manager.list_models(RefreshStrategy::Online).await; assert_eq!(models_mock.requests().len(), 1); } + +#[test] +fn interrupted_fork_snapshot_appends_interrupt_boundary() { + let committed_history = + InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("hello"))]); + + assert_eq!( + serde_json::to_value( + append_interrupted_boundary(committed_history, /*turn_id*/ None).get_rollout_items() + ) + .expect("serialize interrupted fork history"), + serde_json::to_value(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(interrupted_turn_history_marker()), + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: None, + reason: TurnAbortReason::Interrupted, + })), + ]) + .expect("serialize expected interrupted fork history"), + ); + assert_eq!( + serde_json::to_value( + append_interrupted_boundary(InitialHistory::New, /*turn_id*/ None).get_rollout_items() + ) + .expect("serialize interrupted empty fork history"), + serde_json::to_value(vec![ + RolloutItem::ResponseItem(interrupted_turn_history_marker()), + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: None, + reason: TurnAbortReason::Interrupted, + })), + ]) + .expect("serialize expected interrupted empty history"), + ); +} + +#[test] +fn interrupted_snapshot_is_not_mid_turn() { + let interrupted_history = InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(assistant_msg("partial")), + RolloutItem::ResponseItem(interrupted_turn_history_marker()), + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + })), + ]); + + assert_eq!( + snapshot_turn_state(&interrupted_history), + SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); +} + +#[test] +fn completed_legacy_event_history_is_not_mid_turn() { + let completed_history = InitialHistory::Forked(vec![ + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".to_string(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "done".to_string(), + phase: None, + memory_citation: None, + })), + ]); + + assert_eq!( + snapshot_turn_state(&completed_history), + SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); +} + +#[test] +fn mixed_response_and_legacy_user_event_history_is_mid_turn() { + let mixed_history = InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".to_string(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + ]); + + assert_eq!( + snapshot_turn_state(&mixed_history), + SnapshotTurnState { + ends_mid_turn: true, + active_turn_id: None, + active_turn_start_index: None, + }, + ); +} + +#[tokio::test] +async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_history() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.clone(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let manager = ThreadManager::new( + &config, + auth_manager.clone(), + SessionSource::Exec, + CollaborationModesConfig::default(), + ); + + let source = manager + .resume_thread_with_history( + config.clone(), + InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]), + auth_manager, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("create source thread from completed history"); + let source_path = source + .thread + .rollout_path() + .expect("source rollout path should exist"); + let source_history = RolloutRecorder::get_rollout_history(&source_path) + .await + .expect("read source rollout history"); + let source_snapshot_state = snapshot_turn_state(&source_history); + assert!(source_snapshot_state.ends_mid_turn); + let expected_turn_id = source_snapshot_state.active_turn_id.clone(); + assert_eq!(expected_turn_id, None); + + let forked = manager + .fork_thread( + ForkSnapshot::Interrupted, + config, + source_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("fork interrupted snapshot"); + let forked_path = forked + .thread + .rollout_path() + .expect("forked rollout path should exist"); + let history = RolloutRecorder::get_rollout_history(&forked_path) + .await + .expect("read forked rollout history"); + assert!(!snapshot_turn_state(&history).ends_mid_turn); + let rollout_items: Vec<_> = history + .get_rollout_items() + .into_iter() + .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .collect(); + let interrupted_marker_json = + serde_json::to_value(RolloutItem::ResponseItem(interrupted_turn_history_marker())) + .expect("serialize interrupted marker"); + let interrupted_abort_json = serde_json::to_value(RolloutItem::EventMsg( + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: expected_turn_id, + reason: TurnAbortReason::Interrupted, + }), + )) + .expect("serialize interrupted abort event"); + assert_eq!( + rollout_items + .iter() + .filter(|item| { + serde_json::to_value(item).expect("serialize rollout item") + == interrupted_marker_json + }) + .count(), + 1, + ); + assert_eq!( + rollout_items + .iter() + .filter(|item| { + serde_json::to_value(item).expect("serialize rollout item") + == interrupted_abort_json + }) + .count(), + 1, + ); +} + +#[tokio::test] +async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.clone(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let manager = ThreadManager::new( + &config, + auth_manager.clone(), + SessionSource::Exec, + CollaborationModesConfig::default(), + ); + + let source = manager + .resume_thread_with_history( + config.clone(), + InitialHistory::Forked(vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-explicit".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]), + auth_manager, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("create source thread from explicit partial history"); + let source_path = source + .thread + .rollout_path() + .expect("source rollout path should exist"); + let source_history = RolloutRecorder::get_rollout_history(&source_path) + .await + .expect("read source rollout history"); + let source_snapshot_state = snapshot_turn_state(&source_history); + assert_eq!( + source_snapshot_state, + SnapshotTurnState { + ends_mid_turn: true, + active_turn_id: Some("turn-explicit".to_string()), + active_turn_start_index: Some(1), + }, + ); + + let forked = manager + .fork_thread( + ForkSnapshot::Interrupted, + config, + source_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("fork interrupted snapshot"); + let forked_path = forked + .thread + .rollout_path() + .expect("forked rollout path should exist"); + let history = RolloutRecorder::get_rollout_history(&forked_path) + .await + .expect("read forked rollout history"); + let rollout_items: Vec<_> = history + .get_rollout_items() + .into_iter() + .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .collect(); + + assert!(rollout_items.iter().any(|item| { + matches!( + item, + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some(turn_id), + reason: TurnAbortReason::Interrupted, + })) if turn_id == "turn-explicit" + ) + })); +} + +#[tokio::test] +async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_source() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.clone(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let manager = ThreadManager::new( + &config, + auth_manager.clone(), + SessionSource::Exec, + CollaborationModesConfig::default(), + ); + + let source = manager + .resume_thread_with_history( + config.clone(), + InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]), + auth_manager, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("create source thread from partial history"); + let source_path = source + .thread + .rollout_path() + .expect("source rollout path should exist"); + let source_history = RolloutRecorder::get_rollout_history(&source_path) + .await + .expect("read source rollout history"); + assert!(snapshot_turn_state(&source_history).ends_mid_turn); + manager.remove_thread(&source.thread_id).await; + + let forked = manager + .fork_thread( + ForkSnapshot::Interrupted, + config.clone(), + source_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("fork interrupted snapshot"); + let forked_path = forked + .thread + .rollout_path() + .expect("forked rollout path should exist"); + let history = RolloutRecorder::get_rollout_history(&forked_path) + .await + .expect("read forked rollout history"); + assert!(!snapshot_turn_state(&history).ends_mid_turn); + + let forked_rollout_items: Vec<_> = history + .get_rollout_items() + .into_iter() + .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .collect(); + let interrupted_marker_json = + serde_json::to_value(RolloutItem::ResponseItem(interrupted_turn_history_marker())) + .expect("serialize interrupted marker"); + assert_eq!( + forked_rollout_items + .iter() + .filter(|item| { + serde_json::to_value(item).expect("serialize forked rollout item") + == interrupted_marker_json + }) + .count(), + 1, + ); + + manager.remove_thread(&forked.thread_id).await; + let reforked = manager + .fork_thread( + ForkSnapshot::Interrupted, + config, + forked_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("re-fork interrupted snapshot"); + let reforked_path = reforked + .thread + .rollout_path() + .expect("re-forked rollout path should exist"); + let reforked_history = RolloutRecorder::get_rollout_history(&reforked_path) + .await + .expect("read re-forked rollout history"); + let reforked_rollout_items: Vec<_> = reforked_history + .get_rollout_items() + .into_iter() + .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .collect(); + + assert_eq!( + reforked_rollout_items + .iter() + .filter(|item| { + serde_json::to_value(item).expect("serialize re-forked rollout item") + == interrupted_marker_json + }) + .count(), + 1, + ); + assert_eq!( + reforked_rollout_items + .iter() + .filter(|item| { + matches!( + item, + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + .. + })) + ) + }) + .count(), + 1, + ); +} diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 0b7852af0e..2007aab55b 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -12,6 +12,7 @@ use super::compact::FIRST_REPLY; use super::compact::SUMMARY_TEXT; use anyhow::Result; use codex_core::CodexThread; +use codex_core::ForkSnapshot; use codex_core::ThreadManager; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::config::Config; @@ -383,8 +384,13 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<() let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; let summary_after_second_compact = 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]; + let mut expected_after_second_compact_user_texts = vec![ + "hello world".to_string(), + "AFTER_COMPACT".to_string(), + "AFTER_RESUME".to_string(), + "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"); @@ -841,8 +847,14 @@ async fn fork_thread( path: std::path::PathBuf, nth_user_message: usize, ) -> Arc { - Box::pin(manager.fork_thread(nth_user_message, config.clone(), path, false, None)) - .await - .expect("fork conversation") - .thread + Box::pin(manager.fork_thread( + ForkSnapshot::TruncateBeforeNthUserMessage(nth_user_message), + config.clone(), + path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + )) + .await + .expect("fork conversation") + .thread } diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 96fef440c8..a16e460204 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -1,3 +1,4 @@ +use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::parse_turn_item; use codex_protocol::items::TurnItem; @@ -110,7 +111,13 @@ async fn fork_thread_twice_drops_to_first_message() { thread: codex_fork1, .. } = thread_manager - .fork_thread(1, config_for_fork.clone(), base_path.clone(), false, None) + .fork_thread( + ForkSnapshot::TruncateBeforeNthUserMessage(1), + config_for_fork.clone(), + base_path.clone(), + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) .await .expect("fork 1"); @@ -129,7 +136,13 @@ async fn fork_thread_twice_drops_to_first_message() { thread: codex_fork2, .. } = thread_manager - .fork_thread(0, config_for_fork.clone(), fork1_path.clone(), false, None) + .fork_thread( + ForkSnapshot::TruncateBeforeNthUserMessage(0), + config_for_fork.clone(), + fork1_path.clone(), + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) .await .expect("fork 2"); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 2838233e7f..30180c8cf6 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::ForkSnapshot; use codex_core::config::Constrained; use codex_execpolicy::Policy; use codex_protocol::models::DeveloperInstructions; @@ -419,7 +420,13 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { fork_config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted); let forked = initial .thread_manager - .fork_thread(usize::MAX, fork_config, rollout_path, false, None) + .fork_thread( + ForkSnapshot::Interrupted, + fork_config, + rollout_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) .await?; forked .thread diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 55bb0ac284..0df7d583e5 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -531,7 +531,9 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let script = "apply_patch <<'EOF'\n*** Begin Patch\n*** Add File: snapshot-apply.txt\n+hello from snapshot\n*** End Patch\nEOF\n"; let args = json!({ "command": script, - "timeout_ms": 1_000, + // The intercepted apply_patch path self-invokes codex, which can take + // longer than a second in Bazel macOS test environments. + "timeout_ms": 5_000, }); let call_id = "shell-snapshot-apply-patch"; let responses = vec![ diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0d77e3c7f8..5b43840b49 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -57,6 +57,7 @@ use codex_app_server_protocol::RequestId; use codex_arg0::Arg0DispatchPaths; use codex_core::AuthManager; use codex_core::CodexAuth; +use codex_core::ForkSnapshot; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -2502,7 +2503,7 @@ impl App { ); let forked = thread_manager .fork_thread( - usize::MAX, + ForkSnapshot::Interrupted, config.clone(), target_session.path.clone(), /*persist_extended_history*/ false, @@ -2925,7 +2926,7 @@ impl App { match self .server .fork_thread( - usize::MAX, + ForkSnapshot::Interrupted, self.config.clone(), path.clone(), /*persist_extended_history*/ false, From db8bb7236d1266dd9596dc01cb46e414fd794538 Mon Sep 17 00:00:00 2001 From: alexsong-oai Date: Mon, 23 Mar 2026 19:08:30 -0700 Subject: [PATCH 35/38] Add plugin-creator as system skill (#15554) --- .../assets/samples/plugin-creator/SKILL.md | 148 +++++++++ .../samples/plugin-creator/agents/openai.yaml | 6 + .../assets/plugin-creator-small.svg | 3 + .../plugin-creator/assets/plugin-creator.png | Bin 0 -> 1563 bytes .../references/plugin-json-spec.md | 166 ++++++++++ .../scripts/create_basic_plugin.py | 291 ++++++++++++++++++ 6 files changed, 614 insertions(+) create mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md create mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml create mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator-small.svg create mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator.png create mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md create mode 100755 codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md new file mode 100644 index 0000000000..28d47b7548 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md @@ -0,0 +1,148 @@ +--- +name: plugin-creator +description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, and baseline placeholders you can edit before publishing or testing. Use when Codex needs to create a new local plugin, add optional plugin structure, or generate or update repo-root `.agents/plugins/marketplace.json` entries for plugin ordering and availability metadata. +--- + +# Plugin Creator + +## Quick Start + +1. Run the scaffold script: + +```bash + # Plugin names are normalized to lower-case hyphen-case and must be <= 64 chars. + # The generated folder and plugin.json name are always the same. +# Run from repo root (or replace .agents/... with the absolute path to this SKILL). +# By default creates in /plugins/. +python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py +``` + +2. Open `/.codex-plugin/plugin.json` and replace `[TODO: ...]` placeholders. + +3. Generate or update the repo marketplace entry when the plugin should appear in Codex UI ordering: + +```bash +# marketplace.json always lives at /.agents/plugins/marketplace.json +python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin --with-marketplace +``` + +4. Generate/adjust optional companion folders as needed: + +```bash +python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin --path \ + --with-skills --with-hooks --with-scripts --with-assets --with-mcp --with-apps --with-marketplace +``` + +`` is the directory where the plugin folder `` will be created (for example `~/code/plugins`). + +## What this skill creates + +- Creates plugin root at `///`. +- Always creates `///.codex-plugin/plugin.json`. +- Fills the manifest with the full schema shape, placeholder values, and the complete `interface` section. +- Creates or updates `/.agents/plugins/marketplace.json` when `--with-marketplace` is set. + - If the marketplace file does not exist yet, seed top-level `name` plus `interface.displayName` placeholders before adding the first plugin entry. +- `` is normalized using skill-creator naming rules: + - `My Plugin` → `my-plugin` + - `My--Plugin` → `my-plugin` + - underscores, spaces, and punctuation are converted to `-` + - result is lower-case hyphen-delimited with consecutive hyphens collapsed +- Supports optional creation of: + - `skills/` + - `hooks/` + - `scripts/` + - `assets/` + - `.mcp.json` + - `.app.json` + +## Marketplace workflow + +- `marketplace.json` always lives at `/.agents/plugins/marketplace.json`. +- Marketplace root metadata supports top-level `name` plus optional `interface.displayName`. +- Treat plugin order in `plugins[]` as render order in Codex. Append new entries unless a user explicitly asks to reorder the list. +- `displayName` belongs inside the marketplace `interface` object, not individual `plugins[]` entries. +- Each generated marketplace entry must include all of: + - `policy.installation` + - `policy.authentication` + - `category` +- Default new entries to: + - `policy.installation: "AVAILABLE"` + - `policy.authentication: "ON_INSTALL"` +- Override defaults only when the user explicitly specifies another allowed value. +- Allowed `policy.installation` values: + - `NOT_AVAILABLE` + - `AVAILABLE` + - `INSTALLED_BY_DEFAULT` +- Allowed `policy.authentication` values: + - `ON_INSTALL` + - `ON_USE` +- Treat `policy.products` as an override. Omit it unless the user explicitly requests product gating. +- The generated plugin entry shape is: + +```json +{ + "name": "plugin-name", + "source": { + "source": "local", + "path": "./plugins/plugin-name" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" +} +``` + +- Use `--force` only when intentionally replacing an existing marketplace entry for the same plugin name. +- If `/.agents/plugins/marketplace.json` does not exist yet, create it with top-level `"name"`, an `"interface"` object containing `"displayName"`, and a `plugins` array, then add the new entry. + +- For a brand-new marketplace file, the root object should look like: + +```json +{ + "name": "[TODO: marketplace-name]", + "interface": { + "displayName": "[TODO: Marketplace Display Name]" + }, + "plugins": [ + { + "name": "plugin-name", + "source": { + "source": "local", + "path": "./plugins/plugin-name" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} +``` + +## Required behavior + +- Outer folder name and `plugin.json` `"name"` are always the same normalized plugin name. +- Do not remove required structure; keep `.codex-plugin/plugin.json` present. +- Keep manifest values as placeholders until a human or follow-up step explicitly fills them. +- If creating files inside an existing plugin path, use `--force` only when overwrite is intentional. +- Preserve any existing marketplace `interface.displayName`. +- When generating marketplace entries, always write `policy.installation`, `policy.authentication`, and `category` even if their values are defaults. +- Add `policy.products` only when the user explicitly asks for that override. +- Keep marketplace `source.path` relative to repo root as `./plugins/`. + +## Reference to exact spec sample + +For the exact canonical sample JSON for both plugin manifests and marketplace entries, use: + +- `references/plugin-json-spec.md` + +## Validation + +After editing `SKILL.md`, run: + +```bash +python3 /scripts/quick_validate.py .agents/skills/plugin-creator +``` diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml b/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml new file mode 100644 index 0000000000..979b49859f --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Plugin Creator" + short_description: "Scaffold plugins and marketplace entries" + default_prompt: "Use $plugin-creator to scaffold a plugin with placeholder plugin.json, optional structure, and a marketplace.json entry." + icon_small: "./assets/plugin-creator-small.svg" + icon_large: "./assets/plugin-creator.png" diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator-small.svg b/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator-small.svg new file mode 100644 index 0000000000..c6e4f67c62 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator.png b/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator.png new file mode 100644 index 0000000000000000000000000000000000000000..4f3d6d82fa78fbdce97af3c17f6a25c683aa3290 GIT binary patch literal 1563 zcmV+$2ITpPP)1#i4F!Dtb@FnH%p6OalPi4TDy zMcTdqNqhk%K`*Qbx>_X{K4hvjHd<=hH0fq{Gsiib9J_g*IrFfyGygA?&1Rpl>Iw2qhkb ze|)YB#{A1DHe~H{=}oVwFPPA*37~O^y*^+fpb`thKY1$)zGE1LrVQMsu}jdkKwuIF z!Y>Y|aerZBkl8tR2W{Vc1+7>_fXyPMKjmfh7264^#1Q8f8yfM)Gsj@1QV6g#maH@I zP%I~8Ek1&*Sz<4`O%n=C#xm?`ka z@f*eCnG*Imx=o;)FU8GPEG9%?fE(jGa4*wlOu)drquT{LDa8yFs(BEJAz$>R*gPVE zqU#+{sHQ>IV#p$cqEO9(Y=$ALItt^-S@v#+A*(J*LO`5i$f|}?GICI;ra;_c$ihcq zX6#?7t76DPMwx(OH38xkLlzp!uBppVEb<_JVaNhUVVGdAj!ZzYNQ3x|AuFRSya%H+ z?dbZhQ0jjifQKRr5)?-HzW@Ca#_cg2y7h{`C>$TMzDr80D_JB#g2uq?7v-;0HimyF z%!H*6!ecrjR#+G^-I_yvejNAeo`a<|YBMrB2#6*u;OYX9V;b-SyY zmSuIfx3`<{v8qoM4|80YXzgSg7L69b@21RF+`z))FOtR$uxAc?!1W*3JruXoD~WPVK^^v5W;X* zpdf_dzQ8~TL#;qS2tz&JK?uWJzP@9~zdK179`pAt+{P25gyAjUK={;^Ves}2U?==6 zNf;tu_96V2E4^TR<-qwx`(A%qKJ|LP3BwTiGa(EWhXUIZ!jHo1q$CkT~d#9UdUR zH40%o$QuZQi$i}mMj?#VDT6HUKah5JtJNrku|D+)DV1De$d*y~ogWBegVa9;v@4x` z{E^m^dv>Y3)-b^IHelgvz;bpZlf!BnL!=DS*wpgU)oZ_Yc0Tbalu)#Ku-%=0qSQQw zNPQo|`ICR&zKgduf@(L>gD6zp1DQ&W{*=*XftU-Bx z{{AAaUi%GI*nGtbL!>kzrBZ3#>yIi-O6)L1N+5D`wYCsZVu>MA1<7o7SqLbx#Skfh zh!xgWmj{RvYYdUEo2<$LK#4tu$n`2~?FwtF{91jn{I@yA@4gUs43X;z*=U8eRh}nB zZ@!E}7h*pdGS?vX=F0UC71dDKEG$029y`&?4tIz02f3n9Xus>kIZ7E~2%2`79eMr( zBrMmT%M$w#UDy9A6bf}v=-|G+IQzzn<BbTJjk&~1^x!BXwGidKmHbOD6^2FPh=j>?` zzdy/.agents/plugins/marketplace.json` +- Local plugin: `~/.agents/plugins/marketplace.json` + +```json +{ + "name": "openai-curated", + "interface": { + "displayName": "ChatGPT Official" + }, + "plugins": [ + { + "name": "linear", + "source": { + "source": "local", + "path": "./plugins/linear" + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + "category": "Productivity" + } + ] +} +``` + +## Marketplace field guide + +### Top-level fields + +- `name` (`string`): Marketplace identifier or catalog name. +- `interface` (`object`, optional): Marketplace presentation metadata. +- `plugins` (`array`): Ordered plugin entries. This order determines how Codex renders plugins. + +### `interface` fields + +- `displayName` (`string`, optional): User-facing marketplace title. + +### Plugin entry fields + +- `name` (`string`): Plugin identifier. Match the plugin folder name and `plugin.json` `name`. +- `source` (`object`): Plugin source descriptor. + - `source` (`string`): Use `local` for this repo workflow. + - `path` (`string`): Relative plugin path based on the marketplace root. + - Repo plugin: `./plugins/` + - Local plugin in `~/.agents/plugins/marketplace.json`: `./.codex/plugins/` +- `policy` (`object`): Marketplace policy block. Always include it. + - `installation` (`string`): Availability policy. + - Allowed values: `NOT_AVAILABLE`, `AVAILABLE`, `INSTALLED_BY_DEFAULT` + - Default for new entries: `AVAILABLE` + - `authentication` (`string`): Authentication timing policy. + - Allowed values: `ON_INSTALL`, `ON_USE` + - Default for new entries: `ON_INSTALL` + - `products` (`array` of `string`, optional): Product override for this plugin entry. Omit it unless product gating is explicitly requested. +- `category` (`string`): Display category bucket. Always include it. + +### Marketplace generation rules + +- `displayName` belongs under the top-level `interface` object, not individual plugin entries. +- When creating a new marketplace file from scratch, seed `interface.displayName` alongside top-level `name`. +- Always include `policy.installation`, `policy.authentication`, and `category` on every generated or updated plugin entry. +- Treat `policy.products` as an override and omit it unless explicitly requested. +- Append new entries unless the user explicitly requests reordering. +- Replace an existing entry for the same plugin only when overwrite is intentional. +- Choose marketplace location to match the plugin destination: + - Repo plugin: `/.agents/plugins/marketplace.json` + - Local plugin: `~/.agents/plugins/marketplace.json` diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py new file mode 100755 index 0000000000..5a61093164 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Scaffold a plugin directory and optionally update marketplace.json.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +MAX_PLUGIN_NAME_LENGTH = 64 +DEFAULT_PLUGIN_PARENT = Path.cwd() / "plugins" +DEFAULT_MARKETPLACE_PATH = Path.cwd() / ".agents" / "plugins" / "marketplace.json" +DEFAULT_INSTALL_POLICY = "AVAILABLE" +DEFAULT_AUTH_POLICY = "ON_INSTALL" +DEFAULT_CATEGORY = "Productivity" +DEFAULT_MARKETPLACE_DISPLAY_NAME = "[TODO: Marketplace Display Name]" +VALID_INSTALL_POLICIES = {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"} +VALID_AUTH_POLICIES = {"ON_INSTALL", "ON_USE"} + + +def normalize_plugin_name(plugin_name: str) -> str: + """Normalize a plugin name to lowercase hyphen-case.""" + normalized = plugin_name.strip().lower() + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + normalized = normalized.strip("-") + normalized = re.sub(r"-{2,}", "-", normalized) + return normalized + + +def validate_plugin_name(plugin_name: str) -> None: + if not plugin_name: + raise ValueError("Plugin name must include at least one letter or digit.") + if len(plugin_name) > MAX_PLUGIN_NAME_LENGTH: + raise ValueError( + f"Plugin name '{plugin_name}' is too long ({len(plugin_name)} characters). " + f"Maximum is {MAX_PLUGIN_NAME_LENGTH} characters." + ) + + +def build_plugin_json(plugin_name: str) -> dict: + return { + "name": plugin_name, + "version": "[TODO: 1.2.0]", + "description": "[TODO: Brief plugin description]", + "author": { + "name": "[TODO: Author Name]", + "email": "[TODO: author@example.com]", + "url": "[TODO: https://github.com/author]", + }, + "homepage": "[TODO: https://docs.example.com/plugin]", + "repository": "[TODO: https://github.com/author/plugin]", + "license": "[TODO: MIT]", + "keywords": ["[TODO: keyword1]", "[TODO: keyword2]"], + "skills": "[TODO: ./skills/]", + "hooks": "[TODO: ./hooks.json]", + "mcpServers": "[TODO: ./.mcp.json]", + "apps": "[TODO: ./.app.json]", + "interface": { + "displayName": "[TODO: Plugin Display Name]", + "shortDescription": "[TODO: Short description for subtitle]", + "longDescription": "[TODO: Long description for details page]", + "developerName": "[TODO: OpenAI]", + "category": "[TODO: Productivity]", + "capabilities": ["[TODO: Interactive]", "[TODO: Write]"], + "websiteURL": "[TODO: https://openai.com/]", + "privacyPolicyURL": "[TODO: https://openai.com/policies/row-privacy-policy/]", + "termsOfServiceURL": "[TODO: https://openai.com/policies/row-terms-of-use/]", + "defaultPrompt": [ + "[TODO: Summarize my inbox and draft replies for me.]", + "[TODO: Find open bugs and turn them into tickets.]", + "[TODO: Review today's meetings and flag gaps.]", + ], + "brandColor": "[TODO: #3B82F6]", + "composerIcon": "[TODO: ./assets/icon.png]", + "logo": "[TODO: ./assets/logo.png]", + "screenshots": [ + "[TODO: ./assets/screenshot1.png]", + "[TODO: ./assets/screenshot2.png]", + "[TODO: ./assets/screenshot3.png]", + ], + }, + } + + +def build_marketplace_entry( + plugin_name: str, + install_policy: str, + auth_policy: str, + category: str, +) -> dict[str, Any]: + return { + "name": plugin_name, + "source": { + "source": "local", + "path": f"./plugins/{plugin_name}", + }, + "policy": { + "installation": install_policy, + "authentication": auth_policy, + }, + "category": category, + } + + +def load_json(path: Path) -> dict[str, Any]: + with path.open() as handle: + return json.load(handle) + + +def build_default_marketplace() -> dict[str, Any]: + return { + "name": "[TODO: marketplace-name]", + "interface": { + "displayName": DEFAULT_MARKETPLACE_DISPLAY_NAME, + }, + "plugins": [], + } + + +def validate_marketplace_interface(payload: dict[str, Any]) -> None: + interface = payload.get("interface") + if interface is not None and not isinstance(interface, dict): + raise ValueError("marketplace.json field 'interface' must be an object.") + + +def update_marketplace_json( + marketplace_path: Path, + plugin_name: str, + install_policy: str, + auth_policy: str, + category: str, + force: bool, +) -> None: + if marketplace_path.exists(): + payload = load_json(marketplace_path) + else: + payload = build_default_marketplace() + + if not isinstance(payload, dict): + raise ValueError(f"{marketplace_path} must contain a JSON object.") + + validate_marketplace_interface(payload) + + plugins = payload.setdefault("plugins", []) + if not isinstance(plugins, list): + raise ValueError(f"{marketplace_path} field 'plugins' must be an array.") + + new_entry = build_marketplace_entry(plugin_name, install_policy, auth_policy, category) + + for index, entry in enumerate(plugins): + if isinstance(entry, dict) and entry.get("name") == plugin_name: + if not force: + raise FileExistsError( + f"Marketplace entry '{plugin_name}' already exists in {marketplace_path}. " + "Use --force to overwrite that entry." + ) + plugins[index] = new_entry + break + else: + plugins.append(new_entry) + + write_json(marketplace_path, payload, force=True) + + +def write_json(path: Path, data: dict, force: bool) -> None: + if path.exists() and not force: + raise FileExistsError(f"{path} already exists. Use --force to overwrite.") + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + + +def create_stub_file(path: Path, payload: dict, force: bool) -> None: + if path.exists() and not force: + return + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as handle: + json.dump(payload, handle, indent=2) + handle.write("\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create a plugin skeleton with placeholder plugin.json." + ) + parser.add_argument("plugin_name") + parser.add_argument( + "--path", + default=str(DEFAULT_PLUGIN_PARENT), + help="Parent directory for plugin creation (defaults to /plugins)", + ) + parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory") + parser.add_argument("--with-hooks", action="store_true", help="Create hooks/ directory") + parser.add_argument("--with-scripts", action="store_true", help="Create scripts/ directory") + parser.add_argument("--with-assets", action="store_true", help="Create assets/ directory") + parser.add_argument("--with-mcp", action="store_true", help="Create .mcp.json placeholder") + parser.add_argument("--with-apps", action="store_true", help="Create .app.json placeholder") + parser.add_argument( + "--with-marketplace", + action="store_true", + help="Create or update /.agents/plugins/marketplace.json", + ) + parser.add_argument( + "--marketplace-path", + default=str(DEFAULT_MARKETPLACE_PATH), + help="Path to marketplace.json (defaults to /.agents/plugins/marketplace.json)", + ) + parser.add_argument( + "--install-policy", + default=DEFAULT_INSTALL_POLICY, + choices=sorted(VALID_INSTALL_POLICIES), + help="Marketplace policy.installation value", + ) + parser.add_argument( + "--auth-policy", + default=DEFAULT_AUTH_POLICY, + choices=sorted(VALID_AUTH_POLICIES), + help="Marketplace policy.authentication value", + ) + parser.add_argument( + "--category", + default=DEFAULT_CATEGORY, + help="Marketplace category value", + ) + parser.add_argument("--force", action="store_true", help="Overwrite existing files") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + raw_plugin_name = args.plugin_name + plugin_name = normalize_plugin_name(raw_plugin_name) + if plugin_name != raw_plugin_name: + print(f"Note: Normalized plugin name from '{raw_plugin_name}' to '{plugin_name}'.") + validate_plugin_name(plugin_name) + + plugin_root = (Path(args.path).expanduser().resolve() / plugin_name) + plugin_root.mkdir(parents=True, exist_ok=True) + + plugin_json_path = plugin_root / ".codex-plugin" / "plugin.json" + write_json(plugin_json_path, build_plugin_json(plugin_name), args.force) + + optional_directories = { + "skills": args.with_skills, + "hooks": args.with_hooks, + "scripts": args.with_scripts, + "assets": args.with_assets, + } + for folder, enabled in optional_directories.items(): + if enabled: + (plugin_root / folder).mkdir(parents=True, exist_ok=True) + + if args.with_mcp: + create_stub_file( + plugin_root / ".mcp.json", + {"mcpServers": {}}, + args.force, + ) + + if args.with_apps: + create_stub_file( + plugin_root / ".app.json", + { + "apps": {}, + }, + args.force, + ) + + if args.with_marketplace: + marketplace_path = Path(args.marketplace_path).expanduser().resolve() + update_marketplace_json( + marketplace_path, + plugin_name, + args.install_policy, + args.auth_policy, + args.category, + args.force, + ) + + print(f"Created plugin scaffold: {plugin_root}") + print(f"plugin manifest: {plugin_json_path}") + if args.with_marketplace: + print(f"marketplace manifest: {marketplace_path}") + + +if __name__ == "__main__": + main() From cd21456609e3d08841bc97aa5127826b6ab20d67 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 23 Mar 2026 20:34:57 -0700 Subject: [PATCH 36/38] codex: fix CI failure on PR #15593 --- MODULE.bazel.lock | 2 +- codex-rs/Cargo.lock | 4 ++-- codex-rs/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 6305cd169b..e0d384767b 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1328,7 +1328,7 @@ "system-configuration-sys_0.6.0": "{\"dependencies\":[{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\"},{\"name\":\"libc\",\"req\":\"^0.2.149\"}],\"features\":{}}", "system-configuration_0.6.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"core-foundation\",\"req\":\"^0.9\"},{\"name\":\"system-configuration-sys\",\"req\":\"^0.6\"}],\"features\":{}}", "tagptr_0.2.0": "{\"dependencies\":[],\"features\":{}}", - "tar_0.4.44": "{\"dependencies\":[{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", + "tar_0.4.45": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"astral-tokio-tar\",\"req\":\"^0.5\"},{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", "tempfile_3.24.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.3\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", "temporal_capi_0.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"unstable\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.1.2\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}", "temporal_rs_0.1.2": "{\"dependencies\":[{\"name\":\"core_maths\",\"req\":\"^0.1.1\"},{\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.64\"},{\"default_features\":false,\"features\":[\"unstable\",\"compiled_data\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"features\":[\"duration\"],\"name\":\"ixdtf\",\"req\":\"^0.6.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"}],\"features\":{\"compiled_data\":[\"tzdb\"],\"default\":[\"sys\"],\"float64_representable_durations\":[],\"log\":[\"dep:log\"],\"std\":[],\"sys\":[\"std\",\"compiled_data\",\"dep:web-time\",\"dep:iana-time-zone\"],\"tzdb\":[\"std\",\"timezone_provider/tzif\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 08a6527dc3..6fc129b357 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -9757,9 +9757,9 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3a1dcb4c82..43438a0575 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -288,7 +288,7 @@ supports-color = "3.0.2" syntect = "5" sys-locale = "0.3.2" tempfile = "3.23.0" -tar = "0.4.44" +tar = "0.4.45" test-log = "0.2.19" textwrap = "0.16.2" thiserror = "2.0.17" From 2227248cd60ab35df5bd13b4f3f54e577564ce3c Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 23 Mar 2026 20:56:15 -0700 Subject: [PATCH 37/38] Extract landlock helpers into codex-sandboxing (#15592) ## Summary - add a new `codex-sandboxing` crate for sandboxing extraction work - move the pure Linux sandbox argv builders and their unit tests out of `codex-core` - keep `core::landlock` as the spawn wrapper and update direct callers to use `codex_sandboxing::landlock` ## Testing - `cargo test -p codex-sandboxing` - `cargo test -p codex-core landlock` - `cargo test -p codex-cli debug_sandbox` - `just argument-comment-lint` ## Notes - this is step 1 of the move plan aimed at minimizing per-PR diffs - no re-exports or no-op proxy methods were added --- codex-rs/Cargo.lock | 11 ++ codex-rs/Cargo.toml | 2 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/debug_sandbox.rs | 2 +- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/landlock.rs | 111 +---------------- codex-rs/core/src/sandboxing/mod.rs | 4 +- codex-rs/sandboxing/BUILD.bazel | 6 + codex-rs/sandboxing/Cargo.toml | 19 +++ codex-rs/sandboxing/src/landlock.rs | 112 ++++++++++++++++++ .../src/landlock_tests.rs | 0 codex-rs/sandboxing/src/lib.rs | 1 + 12 files changed, 158 insertions(+), 112 deletions(-) create mode 100644 codex-rs/sandboxing/BUILD.bazel create mode 100644 codex-rs/sandboxing/Cargo.toml create mode 100644 codex-rs/sandboxing/src/landlock.rs rename codex-rs/{core => sandboxing}/src/landlock_tests.rs (100%) create mode 100644 codex-rs/sandboxing/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 778dee3ea7..f9a523dd9e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1678,6 +1678,7 @@ dependencies = [ "codex-protocol", "codex-responses-api-proxy", "codex-rmcp-client", + "codex-sandboxing", "codex-state", "codex-stdio-to-uds", "codex-terminal-detection", @@ -1885,6 +1886,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-sandboxing", "codex-secrets", "codex-shell-command", "codex-shell-escalation", @@ -2462,6 +2464,15 @@ dependencies = [ "which 8.0.0", ] +[[package]] +name = "codex-sandboxing" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "pretty_assertions", + "serde_json", +] + [[package]] name = "codex-secrets" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 524b61e3b8..db203d83b5 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -42,6 +42,7 @@ members = [ "protocol", "rmcp-client", "responses-api-proxy", + "sandboxing", "stdio-to-uds", "otel", "tui", @@ -130,6 +131,7 @@ codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } +codex-sandboxing = { path = "sandboxing" } codex-secrets = { path = "secrets" } codex-shell-command = { path = "shell-command" } codex-shell-escalation = { path = "shell-escalation" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index c2fd1300c6..7e703efe10 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -36,6 +36,7 @@ codex-mcp-server = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } +codex-sandboxing = { workspace = true } codex-state = { workspace = true } codex-stdio-to-uds = { workspace = true } codex-terminal-detection = { workspace = true } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index c65b6dcad5..71a2661017 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -11,7 +11,6 @@ use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::NetworkProxyAuditMetadata; use codex_core::exec_env::create_env; -use codex_core::landlock::create_linux_sandbox_command_args_for_policies; #[cfg(target_os = "macos")] use codex_core::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; #[cfg(target_os = "macos")] @@ -19,6 +18,7 @@ use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::config_types::SandboxMode; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; use codex_utils_cli::CliConfigOverrides; use tokio::process::Child; use tokio::process::Command as TokioCommand; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 6386c8d424..9c20345170 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -48,6 +48,7 @@ codex-otel = { workspace = true } codex-artifacts = { workspace = true } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } +codex-sandboxing = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index 19b3f7c6af..c1dc4ba542 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -5,6 +5,8 @@ use crate::spawn::spawn_child_async; use codex_network_proxy::NetworkProxy; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_sandboxing::landlock::allow_network_for_proxy; +use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; @@ -59,112 +61,3 @@ where }) .await } - -pub(crate) fn allow_network_for_proxy(enforce_managed_network: bool) -> bool { - // When managed network requirements are active, request proxy-only - // networking from the Linux sandbox helper. Without managed requirements, - // preserve existing behavior. - enforce_managed_network -} - -/// Converts the sandbox policies into the CLI invocation for -/// `codex-linux-sandbox`. -/// -/// The helper performs the actual sandboxing (bubblewrap by default + seccomp) after -/// parsing these arguments. Policy JSON flags are emitted before helper feature -/// flags so the argv order matches the helper's CLI shape. See -/// `docs/linux_sandbox.md` for the Linux semantics. -#[allow(clippy::too_many_arguments)] -pub fn create_linux_sandbox_command_args_for_policies( - command: Vec, - command_cwd: &Path, - sandbox_policy: &SandboxPolicy, - file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, - sandbox_policy_cwd: &Path, - use_legacy_landlock: bool, - allow_network_for_proxy: bool, -) -> Vec { - let sandbox_policy_json = serde_json::to_string(sandbox_policy) - .unwrap_or_else(|err| panic!("failed to serialize sandbox policy: {err}")); - let file_system_policy_json = serde_json::to_string(file_system_sandbox_policy) - .unwrap_or_else(|err| panic!("failed to serialize filesystem sandbox policy: {err}")); - let network_policy_json = serde_json::to_string(&network_sandbox_policy) - .unwrap_or_else(|err| panic!("failed to serialize network sandbox policy: {err}")); - let sandbox_policy_cwd = sandbox_policy_cwd - .to_str() - .unwrap_or_else(|| panic!("cwd must be valid UTF-8")) - .to_string(); - let command_cwd = command_cwd - .to_str() - .unwrap_or_else(|| panic!("command cwd must be valid UTF-8")) - .to_string(); - - let mut linux_cmd: Vec = vec![ - "--sandbox-policy-cwd".to_string(), - sandbox_policy_cwd, - "--command-cwd".to_string(), - command_cwd, - "--sandbox-policy".to_string(), - sandbox_policy_json, - "--file-system-sandbox-policy".to_string(), - file_system_policy_json, - "--network-sandbox-policy".to_string(), - network_policy_json, - ]; - if use_legacy_landlock { - linux_cmd.push("--use-legacy-landlock".to_string()); - } - if allow_network_for_proxy { - linux_cmd.push("--allow-network-for-proxy".to_string()); - } - linux_cmd.push("--".to_string()); - linux_cmd.extend(command); - linux_cmd -} - -/// Converts the sandbox cwd and execution options into the CLI invocation for -/// `codex-linux-sandbox`. -#[cfg(test)] -pub(crate) fn create_linux_sandbox_command_args( - command: Vec, - command_cwd: &Path, - sandbox_policy_cwd: &Path, - use_legacy_landlock: bool, - allow_network_for_proxy: bool, -) -> Vec { - let command_cwd = command_cwd - .to_str() - .unwrap_or_else(|| panic!("command cwd must be valid UTF-8")) - .to_string(); - let sandbox_policy_cwd = sandbox_policy_cwd - .to_str() - .unwrap_or_else(|| panic!("cwd must be valid UTF-8")) - .to_string(); - - let mut linux_cmd: Vec = vec![ - "--sandbox-policy-cwd".to_string(), - sandbox_policy_cwd, - "--command-cwd".to_string(), - command_cwd, - ]; - if use_legacy_landlock { - linux_cmd.push("--use-legacy-landlock".to_string()); - } - if allow_network_for_proxy { - linux_cmd.push("--allow-network-for-proxy".to_string()); - } - - // Separator so that command arguments starting with `-` are not parsed as - // options of the helper itself. - linux_cmd.push("--".to_string()); - - // Append the original tool command. - linux_cmd.extend(command); - - linux_cmd -} - -#[cfg(test)] -#[path = "landlock_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 277ff2b241..fd055d4de1 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -14,8 +14,6 @@ use crate::exec::ExecToolCallOutput; use crate::exec::SandboxType; use crate::exec::StdoutStream; use crate::exec::execute_exec_request; -use crate::landlock::allow_network_for_proxy; -use crate::landlock::create_linux_sandbox_command_args_for_policies; use crate::protocol::SandboxPolicy; #[cfg(target_os = "macos")] use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; @@ -40,6 +38,8 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::ReadOnlyAccess; +use codex_sandboxing::landlock::allow_network_for_proxy; +use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; use macos_permissions::intersect_macos_seatbelt_profile_extensions; diff --git a/codex-rs/sandboxing/BUILD.bazel b/codex-rs/sandboxing/BUILD.bazel new file mode 100644 index 0000000000..b4b3377a79 --- /dev/null +++ b/codex-rs/sandboxing/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "sandboxing", + crate_name = "codex_sandboxing", +) diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml new file mode 100644 index 0000000000..dc6f4b122a --- /dev/null +++ b/codex-rs/sandboxing/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "codex-sandboxing" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_sandboxing" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-protocol = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/sandboxing/src/landlock.rs b/codex-rs/sandboxing/src/landlock.rs new file mode 100644 index 0000000000..8636f9ed04 --- /dev/null +++ b/codex-rs/sandboxing/src/landlock.rs @@ -0,0 +1,112 @@ +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; +use std::path::Path; +pub fn allow_network_for_proxy(enforce_managed_network: bool) -> bool { + // When managed network requirements are active, request proxy-only + // networking from the Linux sandbox helper. Without managed requirements, + // preserve existing behavior. + enforce_managed_network +} + +/// Converts the sandbox policies into the CLI invocation for +/// `codex-linux-sandbox`. +/// +/// The helper performs the actual sandboxing (bubblewrap by default + seccomp) after +/// parsing these arguments. Policy JSON flags are emitted before helper feature +/// flags so the argv order matches the helper's CLI shape. See +/// `docs/linux_sandbox.md` for the Linux semantics. +#[allow(clippy::too_many_arguments)] +pub fn create_linux_sandbox_command_args_for_policies( + command: Vec, + command_cwd: &Path, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + use_legacy_landlock: bool, + allow_network_for_proxy: bool, +) -> Vec { + let sandbox_policy_json = serde_json::to_string(sandbox_policy) + .unwrap_or_else(|err| panic!("failed to serialize sandbox policy: {err}")); + let file_system_policy_json = serde_json::to_string(file_system_sandbox_policy) + .unwrap_or_else(|err| panic!("failed to serialize filesystem sandbox policy: {err}")); + let network_policy_json = serde_json::to_string(&network_sandbox_policy) + .unwrap_or_else(|err| panic!("failed to serialize network sandbox policy: {err}")); + let sandbox_policy_cwd = sandbox_policy_cwd + .to_str() + .unwrap_or_else(|| panic!("cwd must be valid UTF-8")) + .to_string(); + let command_cwd = command_cwd + .to_str() + .unwrap_or_else(|| panic!("command cwd must be valid UTF-8")) + .to_string(); + + let mut linux_cmd: Vec = vec![ + "--sandbox-policy-cwd".to_string(), + sandbox_policy_cwd, + "--command-cwd".to_string(), + command_cwd, + "--sandbox-policy".to_string(), + sandbox_policy_json, + "--file-system-sandbox-policy".to_string(), + file_system_policy_json, + "--network-sandbox-policy".to_string(), + network_policy_json, + ]; + if use_legacy_landlock { + linux_cmd.push("--use-legacy-landlock".to_string()); + } + if allow_network_for_proxy { + linux_cmd.push("--allow-network-for-proxy".to_string()); + } + linux_cmd.push("--".to_string()); + linux_cmd.extend(command); + linux_cmd +} + +/// Converts the sandbox cwd and execution options into the CLI invocation for +/// `codex-linux-sandbox`. +#[cfg_attr(not(test), allow(dead_code))] +fn create_linux_sandbox_command_args( + command: Vec, + command_cwd: &Path, + sandbox_policy_cwd: &Path, + use_legacy_landlock: bool, + allow_network_for_proxy: bool, +) -> Vec { + let command_cwd = command_cwd + .to_str() + .unwrap_or_else(|| panic!("command cwd must be valid UTF-8")) + .to_string(); + let sandbox_policy_cwd = sandbox_policy_cwd + .to_str() + .unwrap_or_else(|| panic!("cwd must be valid UTF-8")) + .to_string(); + + let mut linux_cmd: Vec = vec![ + "--sandbox-policy-cwd".to_string(), + sandbox_policy_cwd, + "--command-cwd".to_string(), + command_cwd, + ]; + if use_legacy_landlock { + linux_cmd.push("--use-legacy-landlock".to_string()); + } + if allow_network_for_proxy { + linux_cmd.push("--allow-network-for-proxy".to_string()); + } + + // Separator so that command arguments starting with `-` are not parsed as + // options of the helper itself. + linux_cmd.push("--".to_string()); + + // Append the original tool command. + linux_cmd.extend(command); + + linux_cmd +} + +#[cfg(test)] +#[path = "landlock_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/landlock_tests.rs b/codex-rs/sandboxing/src/landlock_tests.rs similarity index 100% rename from codex-rs/core/src/landlock_tests.rs rename to codex-rs/sandboxing/src/landlock_tests.rs diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs new file mode 100644 index 0000000000..517579e280 --- /dev/null +++ b/codex-rs/sandboxing/src/lib.rs @@ -0,0 +1 @@ +pub mod landlock; From 431af0807ce18b66643c28e1c9cd1d5d8f1e4a8e Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 23 Mar 2026 21:26:35 -0700 Subject: [PATCH 38/38] Move macOS sandbox builders into codex-sandboxing (#15593) ## Summary - move macOS permission merging/intersection logic and tests from `codex-core` into `codex-sandboxing` - move seatbelt policy builders, permissions logic, SBPL assets, and their tests into `codex-sandboxing` - keep `codex-core` owning only the seatbelt spawn wrapper and switch call sites to import the moved APIs directly ## Notes - no re-exports added - moved the seatbelt tests with the implementation so internal helpers could stay private - local verification is still finishing while this PR is open --- codex-rs/Cargo.lock | 7 + codex-rs/Cargo.toml | 2 +- codex-rs/cli/src/debug_sandbox.rs | 4 +- codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/sandboxing/mod.rs | 14 +- codex-rs/core/src/seatbelt.rs | 567 +----------------- .../runtimes/shell/unix_escalation_tests.rs | 4 +- codex-rs/sandboxing/BUILD.bazel | 5 + codex-rs/sandboxing/Cargo.toml | 7 + codex-rs/sandboxing/src/lib.rs | 5 + .../src}/macos_permissions.rs | 4 +- .../src}/macos_permissions_tests.rs | 0 ...estricted_read_only_platform_defaults.sbpl | 0 codex-rs/sandboxing/src/seatbelt.rs | 540 +++++++++++++++++ .../src/seatbelt_base_policy.sbpl | 0 .../src/seatbelt_network_policy.sbpl | 0 .../src/seatbelt_permissions.rs | 10 +- .../src/seatbelt_permissions_tests.rs | 0 .../src/seatbelt_tests.rs | 81 ++- 19 files changed, 650 insertions(+), 601 deletions(-) rename codex-rs/{core/src/sandboxing => sandboxing/src}/macos_permissions.rs (98%) rename codex-rs/{core/src/sandboxing => sandboxing/src}/macos_permissions_tests.rs (100%) rename codex-rs/{core => sandboxing}/src/restricted_read_only_platform_defaults.sbpl (100%) create mode 100644 codex-rs/sandboxing/src/seatbelt.rs rename codex-rs/{core => sandboxing}/src/seatbelt_base_policy.sbpl (100%) rename codex-rs/{core => sandboxing}/src/seatbelt_network_policy.sbpl (100%) rename codex-rs/{core => sandboxing}/src/seatbelt_permissions.rs (96%) rename codex-rs/{core => sandboxing}/src/seatbelt_permissions_tests.rs (100%) rename codex-rs/{core => sandboxing}/src/seatbelt_tests.rs (94%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f9a523dd9e..c14c633c43 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2468,9 +2468,16 @@ dependencies = [ name = "codex-sandboxing" version = "0.0.0" dependencies = [ + "codex-network-proxy", "codex-protocol", + "codex-utils-absolute-path", + "dirs", + "libc", "pretty_assertions", "serde_json", + "tempfile", + "tracing", + "url", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index db203d83b5..e0c7633733 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -290,7 +290,7 @@ supports-color = "3.0.2" syntect = "5" sys-locale = "0.3.2" tempfile = "3.23.0" -tar = "0.4.44" +tar = "0.4.45" test-log = "0.2.19" textwrap = "0.16.2" thiserror = "2.0.17" diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 71a2661017..03226cced4 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -12,13 +12,13 @@ use codex_core::config::ConfigOverrides; use codex_core::config::NetworkProxyAuditMetadata; use codex_core::exec_env::create_env; #[cfg(target_os = "macos")] -use codex_core::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; -#[cfg(target_os = "macos")] use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::config_types::SandboxMode; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; use codex_utils_cli::CliConfigOverrides; use tokio::process::Child; use tokio::process::Command as TokioCommand; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e51bf45076..cd6b51b7e9 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -93,7 +93,6 @@ mod event_mapping; mod response_debug_context; pub mod review_format; pub mod review_prompts; -mod seatbelt_permissions; mod thread_manager; pub mod web_search; pub mod windows_sandbox_read_grants; diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index fd055d4de1..e9cf082ca4 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -6,8 +6,6 @@ sandbox placement and transformation of portable CommandSpec into a ready‑to‑spawn environment. */ -pub(crate) mod macos_permissions; - use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; @@ -16,10 +14,6 @@ use crate::exec::StdoutStream; use crate::exec::execute_exec_request; use crate::protocol::SandboxPolicy; #[cfg(target_os = "macos")] -use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; -#[cfg(target_os = "macos")] -use crate::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; -#[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use crate::tools::sandboxing::SandboxablePreference; @@ -40,10 +34,14 @@ use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::ReadOnlyAccess; use codex_sandboxing::landlock::allow_network_for_proxy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; +use codex_sandboxing::macos_permissions::intersect_macos_seatbelt_profile_extensions; +use codex_sandboxing::macos_permissions::merge_macos_seatbelt_profile_extensions; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; -use macos_permissions::intersect_macos_seatbelt_profile_extensions; -use macos_permissions::merge_macos_seatbelt_profile_extensions; use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 2d1e0a7b5d..f81016a990 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -1,40 +1,19 @@ #![cfg(target_os = "macos")] -use codex_network_proxy::NetworkProxy; -use codex_network_proxy::PROXY_URL_ENV_KEYS; -use codex_network_proxy::has_proxy_url_env_vars; -use codex_network_proxy::proxy_url_env_value; -use codex_utils_absolute_path::AbsolutePathBuf; -use std::collections::BTreeMap; -use std::collections::BTreeSet; -use std::collections::HashMap; -use std::ffi::CStr; -use std::path::Path; -use std::path::PathBuf; -use tokio::process::Child; -use tracing::warn; -use url::Url; - use crate::protocol::SandboxPolicy; -use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; -use crate::seatbelt_permissions::build_seatbelt_extensions; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; +use codex_network_proxy::NetworkProxy; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; - -const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl"); -const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl"); -const MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS: &str = - include_str!("restricted_read_only_platform_defaults.sbpl"); - -/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin` -/// to defend against an attacker trying to inject a malicious version on the -/// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker -/// already has root access. -pub const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; +use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; +use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use tokio::process::Child; pub async fn spawn_command_under_seatbelt( command: Vec, @@ -45,12 +24,14 @@ pub async fn spawn_command_under_seatbelt( network: Option<&NetworkProxy>, mut env: HashMap, ) -> std::io::Result { - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_for_policies_with_extensions( command, - sandbox_policy, + &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), + NetworkSandboxPolicy::from(sandbox_policy), sandbox_policy_cwd, /*enforce_managed_network*/ false, network, + /*extensions*/ None, ); let arg0 = None; env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); @@ -66,529 +47,3 @@ pub async fn spawn_command_under_seatbelt( }) .await } - -fn is_loopback_host(host: &str) -> bool { - host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1" -} - -fn proxy_scheme_default_port(scheme: &str) -> u16 { - match scheme { - "https" => 443, - "socks5" | "socks5h" | "socks4" | "socks4a" => 1080, - _ => 80, - } -} - -fn proxy_loopback_ports_from_env(env: &HashMap) -> Vec { - let mut ports = BTreeSet::new(); - for key in PROXY_URL_ENV_KEYS { - let Some(proxy_url) = proxy_url_env_value(env, key) else { - continue; - }; - let trimmed = proxy_url.trim(); - if trimmed.is_empty() { - continue; - } - - let candidate = if trimmed.contains("://") { - trimmed.to_string() - } else { - format!("http://{trimmed}") - }; - let Ok(parsed) = Url::parse(&candidate) else { - continue; - }; - let Some(host) = parsed.host_str() else { - continue; - }; - if !is_loopback_host(host) { - continue; - } - - let scheme = parsed.scheme().to_ascii_lowercase(); - let port = parsed - .port() - .unwrap_or_else(|| proxy_scheme_default_port(scheme.as_str())); - ports.insert(port); - } - ports.into_iter().collect() -} - -#[derive(Debug, Default)] -struct ProxyPolicyInputs { - ports: Vec, - has_proxy_config: bool, - allow_local_binding: bool, - unix_domain_socket_policy: UnixDomainSocketPolicy, -} - -#[derive(Debug, Clone)] -// Keep allow-all and allowlist modes disjoint so we don't carry ignored state. -enum UnixDomainSocketPolicy { - AllowAll, - Restricted { allowed: Vec }, -} - -impl Default for UnixDomainSocketPolicy { - fn default() -> Self { - Self::Restricted { allowed: vec![] } - } -} - -#[derive(Debug, Clone)] -struct UnixSocketPathParam { - index: usize, - path: AbsolutePathBuf, -} - -fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs { - if let Some(network) = network { - let mut env = HashMap::new(); - network.apply_to_env(&mut env); - let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { - UnixDomainSocketPolicy::AllowAll - } else { - let allowed = network - .allow_unix_sockets() - .iter() - .filter_map( - |socket_path| match normalize_path_for_sandbox(Path::new(socket_path)) { - Some(path) => Some((path.to_string_lossy().to_string(), path)), - None => { - warn!( - "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" - ); - None - } - }, - ) - .collect::>() - .into_values() - .collect(); - UnixDomainSocketPolicy::Restricted { allowed } - }; - return ProxyPolicyInputs { - ports: proxy_loopback_ports_from_env(&env), - has_proxy_config: has_proxy_url_env_vars(&env), - allow_local_binding: network.allow_local_binding(), - unix_domain_socket_policy, - }; - } - - ProxyPolicyInputs::default() -} - -fn normalize_path_for_sandbox(path: &Path) -> Option { - // `AbsolutePathBuf::from_absolute_path()` normalizes relative paths against the current - // working directory, so keep the explicit check to avoid silently accepting relative entries. - if !path.is_absolute() { - return None; - } - - let absolute_path = AbsolutePathBuf::from_absolute_path(path).ok()?; - let normalized_path = absolute_path - .as_path() - .canonicalize() - .ok() - .and_then(|canonical_path| AbsolutePathBuf::from_absolute_path(canonical_path).ok()); - normalized_path.or(Some(absolute_path)) -} - -fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec { - let mut deduped_paths: BTreeMap = BTreeMap::new(); - let UnixDomainSocketPolicy::Restricted { allowed } = &proxy.unix_domain_socket_policy else { - return vec![]; - }; - for path in allowed { - deduped_paths - .entry(path.to_string_lossy().to_string()) - .or_insert_with(|| path.clone()); - } - - deduped_paths - .into_values() - .enumerate() - .map(|(index, path)| UnixSocketPathParam { index, path }) - .collect() -} - -fn unix_socket_path_param_key(index: usize) -> String { - format!("UNIX_SOCKET_PATH_{index}") -} - -fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> { - unix_socket_path_params(proxy) - .into_iter() - .map(|param| { - ( - unix_socket_path_param_key(param.index), - param.path.into_path_buf(), - ) - }) - .collect() -} - -/// Returns zero or more complete Seatbelt policy lines for unix socket rules. -/// When non-empty, the returned string is newline-terminated so callers can -/// append it directly to larger policy blocks. -fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String { - let socket_params = unix_socket_path_params(proxy); - let has_unix_socket_access = matches!( - proxy.unix_domain_socket_policy, - UnixDomainSocketPolicy::AllowAll - ) || !socket_params.is_empty(); - if !has_unix_socket_access { - return String::new(); - } - - let mut policy = String::new(); - policy.push_str("(allow system-socket (socket-domain AF_UNIX))\n"); - if matches!( - proxy.unix_domain_socket_policy, - UnixDomainSocketPolicy::AllowAll - ) { - // Keep AllowAll genuinely broad here; path qualifiers look narrower - // without a clear macOS behavioral benefit. - policy.push_str("(allow network-bind (local unix-socket))\n"); - policy.push_str("(allow network-outbound (remote unix-socket))\n"); - return policy; - } - - for param in socket_params { - let key = unix_socket_path_param_key(param.index); - // Use subpath so allowlists cover sockets created beneath approved directories. - policy.push_str(&format!( - "(allow network-bind (local unix-socket (subpath (param \"{key}\"))))\n" - )); - policy.push_str(&format!( - "(allow network-outbound (remote unix-socket (subpath (param \"{key}\"))))\n" - )); - } - policy -} - -#[cfg_attr(not(test), allow(dead_code))] -fn dynamic_network_policy( - sandbox_policy: &SandboxPolicy, - enforce_managed_network: bool, - proxy: &ProxyPolicyInputs, -) -> String { - dynamic_network_policy_for_network( - NetworkSandboxPolicy::from(sandbox_policy), - enforce_managed_network, - proxy, - ) -} - -fn dynamic_network_policy_for_network( - network_policy: NetworkSandboxPolicy, - enforce_managed_network: bool, - proxy: &ProxyPolicyInputs, -) -> String { - let should_use_restricted_network_policy = - !proxy.ports.is_empty() || proxy.has_proxy_config || enforce_managed_network; - if should_use_restricted_network_policy { - let mut policy = String::new(); - if proxy.allow_local_binding { - policy.push_str("; allow loopback local binding and loopback traffic\n"); - policy.push_str("(allow network-bind (local ip \"localhost:*\"))\n"); - policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n"); - policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n"); - } - for port in &proxy.ports { - policy.push_str(&format!( - "(allow network-outbound (remote ip \"localhost:{port}\"))\n" - )); - } - let unix_socket_policy = unix_socket_policy(proxy); - if !unix_socket_policy.is_empty() { - policy.push_str("; allow unix domain sockets for local IPC\n"); - policy.push_str(&unix_socket_policy); - } - return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}"); - } - - if proxy.has_proxy_config { - // Proxy configuration is present but we could not infer any valid loopback endpoints. - // Fail closed to avoid silently widening network access in proxy-enforced sessions. - return String::new(); - } - - if enforce_managed_network { - // Managed network requirements are active but no usable proxy endpoints - // are available. Fail closed for network access. - return String::new(); - } - - if network_policy.is_enabled() { - // No proxy env is configured: retain the existing full-network behavior. - format!( - "(allow network-outbound)\n(allow network-inbound)\n{MACOS_SEATBELT_NETWORK_POLICY}" - ) - } else { - String::new() - } -} - -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn create_seatbelt_command_args( - command: Vec, - sandbox_policy: &SandboxPolicy, - sandbox_policy_cwd: &Path, - enforce_managed_network: bool, - network: Option<&NetworkProxy>, -) -> Vec { - create_seatbelt_command_args_with_extensions( - command, - sandbox_policy, - sandbox_policy_cwd, - enforce_managed_network, - network, - /*extensions*/ None, - ) -} - -fn root_absolute_path() -> AbsolutePathBuf { - match AbsolutePathBuf::from_absolute_path(Path::new("/")) { - Ok(path) => path, - Err(err) => panic!("root path must be absolute: {err}"), - } -} - -#[derive(Debug, Clone)] -struct SeatbeltAccessRoot { - root: AbsolutePathBuf, - excluded_subpaths: Vec, -} - -fn build_seatbelt_access_policy( - action: &str, - param_prefix: &str, - roots: Vec, -) -> (String, Vec<(String, PathBuf)>) { - let mut policy_components = Vec::new(); - let mut params = Vec::new(); - - for (index, access_root) in roots.into_iter().enumerate() { - let root = - normalize_path_for_sandbox(access_root.root.as_path()).unwrap_or(access_root.root); - let root_param = format!("{param_prefix}_{index}"); - params.push((root_param.clone(), root.into_path_buf())); - - if access_root.excluded_subpaths.is_empty() { - policy_components.push(format!("(subpath (param \"{root_param}\"))")); - continue; - } - - let mut require_parts = vec![format!("(subpath (param \"{root_param}\"))")]; - for (excluded_index, excluded_subpath) in - access_root.excluded_subpaths.into_iter().enumerate() - { - let excluded_subpath = - normalize_path_for_sandbox(excluded_subpath.as_path()).unwrap_or(excluded_subpath); - let excluded_param = format!("{param_prefix}_{index}_RO_{excluded_index}"); - params.push((excluded_param.clone(), excluded_subpath.into_path_buf())); - require_parts.push(format!( - "(require-not (subpath (param \"{excluded_param}\")))" - )); - } - policy_components.push(format!("(require-all {} )", require_parts.join(" "))); - } - - if policy_components.is_empty() { - (String::new(), Vec::new()) - } else { - ( - format!("(allow {action}\n{}\n)", policy_components.join(" ")), - params, - ) - } -} - -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn create_seatbelt_command_args_with_extensions( - command: Vec, - sandbox_policy: &SandboxPolicy, - sandbox_policy_cwd: &Path, - enforce_managed_network: bool, - network: Option<&NetworkProxy>, - extensions: Option<&MacOsSeatbeltProfileExtensions>, -) -> Vec { - create_seatbelt_command_args_for_policies_with_extensions( - command, - &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), - NetworkSandboxPolicy::from(sandbox_policy), - sandbox_policy_cwd, - enforce_managed_network, - network, - extensions, - ) -} - -pub fn create_seatbelt_command_args_for_policies_with_extensions( - command: Vec, - file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, - sandbox_policy_cwd: &Path, - enforce_managed_network: bool, - network: Option<&NetworkProxy>, - extensions: Option<&MacOsSeatbeltProfileExtensions>, -) -> Vec { - let unreadable_roots = - file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd); - let (file_write_policy, file_write_dir_params) = - if file_system_sandbox_policy.has_full_disk_write_access() { - if unreadable_roots.is_empty() { - // Allegedly, this is more permissive than `(allow file-write*)`. - ( - r#"(allow file-write* (regex #"^/"))"#.to_string(), - Vec::new(), - ) - } else { - build_seatbelt_access_policy( - "file-write*", - "WRITABLE_ROOT", - vec![SeatbeltAccessRoot { - root: root_absolute_path(), - excluded_subpaths: unreadable_roots.clone(), - }], - ) - } - } else { - build_seatbelt_access_policy( - "file-write*", - "WRITABLE_ROOT", - file_system_sandbox_policy - .get_writable_roots_with_cwd(sandbox_policy_cwd) - .into_iter() - .map(|root| SeatbeltAccessRoot { - root: root.root, - excluded_subpaths: root.read_only_subpaths, - }) - .collect(), - ) - }; - - let (file_read_policy, file_read_dir_params) = - if file_system_sandbox_policy.has_full_disk_read_access() { - if unreadable_roots.is_empty() { - ( - "; allow read-only file operations\n(allow file-read*)".to_string(), - Vec::new(), - ) - } else { - let (policy, params) = build_seatbelt_access_policy( - "file-read*", - "READABLE_ROOT", - vec![SeatbeltAccessRoot { - root: root_absolute_path(), - excluded_subpaths: unreadable_roots, - }], - ); - ( - format!("; allow read-only file operations\n{policy}"), - params, - ) - } - } else { - let (policy, params) = build_seatbelt_access_policy( - "file-read*", - "READABLE_ROOT", - file_system_sandbox_policy - .get_readable_roots_with_cwd(sandbox_policy_cwd) - .into_iter() - .map(|root| SeatbeltAccessRoot { - excluded_subpaths: unreadable_roots - .iter() - .filter(|path| path.as_path().starts_with(root.as_path())) - .cloned() - .collect(), - root, - }) - .collect(), - ); - if policy.is_empty() { - (String::new(), params) - } else { - ( - format!("; allow read-only file operations\n{policy}"), - params, - ) - } - }; - - let proxy = proxy_policy_inputs(network); - let network_policy = - dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy); - let seatbelt_extensions = extensions.map_or_else( - || { - // Backward-compatibility default when no extension profile is provided. - build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()) - }, - build_seatbelt_extensions, - ); - - let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults(); - let mut policy_sections = vec![ - MACOS_SEATBELT_BASE_POLICY.to_string(), - file_read_policy, - file_write_policy, - network_policy, - ]; - if include_platform_defaults { - policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string()); - } - if !seatbelt_extensions.policy.is_empty() { - policy_sections.push(seatbelt_extensions.policy.clone()); - } - - let full_policy = policy_sections.join("\n"); - - let dir_params = [ - file_read_dir_params, - file_write_dir_params, - macos_dir_params(), - unix_socket_dir_params(&proxy), - seatbelt_extensions.dir_params, - ] - .concat(); - - let mut seatbelt_args: Vec = vec!["-p".to_string(), full_policy]; - let definition_args = dir_params - .into_iter() - .map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())); - seatbelt_args.extend(definition_args); - seatbelt_args.push("--".to_string()); - seatbelt_args.extend(command); - seatbelt_args -} - -/// Wraps libc::confstr to return a String. -fn confstr(name: libc::c_int) -> Option { - let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1]; - let len = unsafe { libc::confstr(name, buf.as_mut_ptr(), buf.len()) }; - if len == 0 { - return None; - } - // confstr guarantees NUL-termination when len > 0. - let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) }; - cstr.to_str().ok().map(ToString::to_string) -} - -/// Wraps confstr to return a canonicalized PathBuf. -fn confstr_path(name: libc::c_int) -> Option { - let s = confstr(name)?; - let path = PathBuf::from(s); - path.canonicalize().ok().or(Some(path)) -} - -fn macos_dir_params() -> Vec<(String, PathBuf)> { - if let Some(p) = confstr_path(libc::_CS_DARWIN_USER_CACHE_DIR) { - return vec![("DARWIN_USER_CACHE_DIR".to_string(), p)]; - } - vec![] -} - -#[cfg(test)] -#[path = "seatbelt_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 37c1b53a13..3adee9c295 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -20,8 +20,6 @@ use crate::protocol::GranularApprovalConfig; use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; use crate::sandboxing::SandboxPermissions; -#[cfg(target_os = "macos")] -use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use crate::skills::SkillMetadata; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; @@ -40,6 +38,8 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SkillScope; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; use codex_shell_escalation::ExecResult; diff --git a/codex-rs/sandboxing/BUILD.bazel b/codex-rs/sandboxing/BUILD.bazel index b4b3377a79..57837fa809 100644 --- a/codex-rs/sandboxing/BUILD.bazel +++ b/codex-rs/sandboxing/BUILD.bazel @@ -3,4 +3,9 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "sandboxing", crate_name = "codex_sandboxing", + compile_data = [ + "src/restricted_read_only_platform_defaults.sbpl", + "src/seatbelt_base_policy.sbpl", + "src/seatbelt_network_policy.sbpl", + ], ) diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index dc6f4b122a..0e2ab443a0 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -12,8 +12,15 @@ path = "src/lib.rs" workspace = true [dependencies] +codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } +dirs = { workspace = true } +libc = { workspace = true } serde_json = { workspace = true } +tracing = { workspace = true, features = ["log"] } +url = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 517579e280..17291ec2ae 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -1 +1,6 @@ pub mod landlock; +pub mod macos_permissions; +#[cfg(target_os = "macos")] +pub mod seatbelt; +#[cfg(target_os = "macos")] +mod seatbelt_permissions; diff --git a/codex-rs/core/src/sandboxing/macos_permissions.rs b/codex-rs/sandboxing/src/macos_permissions.rs similarity index 98% rename from codex-rs/core/src/sandboxing/macos_permissions.rs rename to codex-rs/sandboxing/src/macos_permissions.rs index 1a409d4bdf..22314937ef 100644 --- a/codex-rs/core/src/sandboxing/macos_permissions.rs +++ b/codex-rs/sandboxing/src/macos_permissions.rs @@ -7,7 +7,7 @@ use codex_protocol::models::MacOsSeatbeltProfileExtensions; /// Merges macOS seatbelt profile extensions by taking the permissive union of /// each permission field. -pub(crate) fn merge_macos_seatbelt_profile_extensions( +pub fn merge_macos_seatbelt_profile_extensions( base: Option<&MacOsSeatbeltProfileExtensions>, permissions: Option<&MacOsSeatbeltProfileExtensions>, ) -> Option { @@ -38,7 +38,7 @@ pub(crate) fn merge_macos_seatbelt_profile_extensions( } } -pub(crate) fn intersect_macos_seatbelt_profile_extensions( +pub fn intersect_macos_seatbelt_profile_extensions( requested: Option, granted: Option, ) -> Option { diff --git a/codex-rs/core/src/sandboxing/macos_permissions_tests.rs b/codex-rs/sandboxing/src/macos_permissions_tests.rs similarity index 100% rename from codex-rs/core/src/sandboxing/macos_permissions_tests.rs rename to codex-rs/sandboxing/src/macos_permissions_tests.rs diff --git a/codex-rs/core/src/restricted_read_only_platform_defaults.sbpl b/codex-rs/sandboxing/src/restricted_read_only_platform_defaults.sbpl similarity index 100% rename from codex-rs/core/src/restricted_read_only_platform_defaults.sbpl rename to codex-rs/sandboxing/src/restricted_read_only_platform_defaults.sbpl diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs new file mode 100644 index 0000000000..e7cca3d2ac --- /dev/null +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -0,0 +1,540 @@ +use codex_network_proxy::NetworkProxy; +use codex_network_proxy::PROXY_URL_ENV_KEYS; +use codex_network_proxy::has_proxy_url_env_vars; +use codex_network_proxy::proxy_url_env_value; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::ffi::CStr; +use std::path::Path; +use std::path::PathBuf; +use tracing::warn; +use url::Url; + +use crate::seatbelt_permissions::build_seatbelt_extensions; + +const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl"); +const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl"); +const MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS: &str = + include_str!("restricted_read_only_platform_defaults.sbpl"); + +/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin` +/// to defend against an attacker trying to inject a malicious version on the +/// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker +/// already has root access. +pub const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; + +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1" +} + +fn proxy_scheme_default_port(scheme: &str) -> u16 { + match scheme { + "https" => 443, + "socks5" | "socks5h" | "socks4" | "socks4a" => 1080, + _ => 80, + } +} + +fn proxy_loopback_ports_from_env(env: &HashMap) -> Vec { + let mut ports = BTreeSet::new(); + for key in PROXY_URL_ENV_KEYS { + let Some(proxy_url) = proxy_url_env_value(env, key) else { + continue; + }; + let trimmed = proxy_url.trim(); + if trimmed.is_empty() { + continue; + } + + let candidate = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + let Ok(parsed) = Url::parse(&candidate) else { + continue; + }; + let Some(host) = parsed.host_str() else { + continue; + }; + if !is_loopback_host(host) { + continue; + } + + let scheme = parsed.scheme().to_ascii_lowercase(); + let port = parsed + .port() + .unwrap_or_else(|| proxy_scheme_default_port(scheme.as_str())); + ports.insert(port); + } + ports.into_iter().collect() +} + +#[derive(Debug, Default)] +struct ProxyPolicyInputs { + ports: Vec, + has_proxy_config: bool, + allow_local_binding: bool, + unix_domain_socket_policy: UnixDomainSocketPolicy, +} + +#[derive(Debug, Clone)] +// Keep allow-all and allowlist modes disjoint so we don't carry ignored state. +enum UnixDomainSocketPolicy { + AllowAll, + Restricted { allowed: Vec }, +} + +impl Default for UnixDomainSocketPolicy { + fn default() -> Self { + Self::Restricted { allowed: vec![] } + } +} + +#[derive(Debug, Clone)] +struct UnixSocketPathParam { + index: usize, + path: AbsolutePathBuf, +} + +fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs { + if let Some(network) = network { + let mut env = HashMap::new(); + network.apply_to_env(&mut env); + let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { + UnixDomainSocketPolicy::AllowAll + } else { + let allowed = network + .allow_unix_sockets() + .iter() + .filter_map( + |socket_path| match normalize_path_for_sandbox(Path::new(socket_path)) { + Some(path) => Some((path.to_string_lossy().to_string(), path)), + None => { + warn!( + "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" + ); + None + } + }, + ) + .collect::>() + .into_values() + .collect(); + UnixDomainSocketPolicy::Restricted { allowed } + }; + return ProxyPolicyInputs { + ports: proxy_loopback_ports_from_env(&env), + has_proxy_config: has_proxy_url_env_vars(&env), + allow_local_binding: network.allow_local_binding(), + unix_domain_socket_policy, + }; + } + + ProxyPolicyInputs::default() +} + +fn normalize_path_for_sandbox(path: &Path) -> Option { + // `AbsolutePathBuf::from_absolute_path()` normalizes relative paths against the current + // working directory, so keep the explicit check to avoid silently accepting relative entries. + if !path.is_absolute() { + return None; + } + + let absolute_path = AbsolutePathBuf::from_absolute_path(path).ok()?; + let normalized_path = absolute_path + .as_path() + .canonicalize() + .ok() + .and_then(|canonical_path| AbsolutePathBuf::from_absolute_path(canonical_path).ok()); + normalized_path.or(Some(absolute_path)) +} + +fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec { + let mut deduped_paths: BTreeMap = BTreeMap::new(); + let UnixDomainSocketPolicy::Restricted { allowed } = &proxy.unix_domain_socket_policy else { + return vec![]; + }; + for path in allowed { + deduped_paths + .entry(path.to_string_lossy().to_string()) + .or_insert_with(|| path.clone()); + } + + deduped_paths + .into_values() + .enumerate() + .map(|(index, path)| UnixSocketPathParam { index, path }) + .collect() +} + +fn unix_socket_path_param_key(index: usize) -> String { + format!("UNIX_SOCKET_PATH_{index}") +} + +fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> { + unix_socket_path_params(proxy) + .into_iter() + .map(|param| { + ( + unix_socket_path_param_key(param.index), + param.path.into_path_buf(), + ) + }) + .collect() +} + +/// Returns zero or more complete Seatbelt policy lines for unix socket rules. +/// When non-empty, the returned string is newline-terminated so callers can +/// append it directly to larger policy blocks. +fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String { + let socket_params = unix_socket_path_params(proxy); + let has_unix_socket_access = matches!( + proxy.unix_domain_socket_policy, + UnixDomainSocketPolicy::AllowAll + ) || !socket_params.is_empty(); + if !has_unix_socket_access { + return String::new(); + } + + let mut policy = String::new(); + policy.push_str("(allow system-socket (socket-domain AF_UNIX))\n"); + if matches!( + proxy.unix_domain_socket_policy, + UnixDomainSocketPolicy::AllowAll + ) { + // Keep AllowAll genuinely broad here; path qualifiers look narrower + // without a clear macOS behavioral benefit. + policy.push_str("(allow network-bind (local unix-socket))\n"); + policy.push_str("(allow network-outbound (remote unix-socket))\n"); + return policy; + } + + for param in socket_params { + let key = unix_socket_path_param_key(param.index); + // Use subpath so allowlists cover sockets created beneath approved directories. + policy.push_str(&format!( + "(allow network-bind (local unix-socket (subpath (param \"{key}\"))))\n" + )); + policy.push_str(&format!( + "(allow network-outbound (remote unix-socket (subpath (param \"{key}\"))))\n" + )); + } + policy +} + +#[cfg_attr(not(test), allow(dead_code))] +fn dynamic_network_policy( + sandbox_policy: &SandboxPolicy, + enforce_managed_network: bool, + proxy: &ProxyPolicyInputs, +) -> String { + dynamic_network_policy_for_network( + NetworkSandboxPolicy::from(sandbox_policy), + enforce_managed_network, + proxy, + ) +} + +fn dynamic_network_policy_for_network( + network_policy: NetworkSandboxPolicy, + enforce_managed_network: bool, + proxy: &ProxyPolicyInputs, +) -> String { + let should_use_restricted_network_policy = + !proxy.ports.is_empty() || proxy.has_proxy_config || enforce_managed_network; + if should_use_restricted_network_policy { + let mut policy = String::new(); + if proxy.allow_local_binding { + policy.push_str("; allow loopback local binding and loopback traffic\n"); + policy.push_str("(allow network-bind (local ip \"localhost:*\"))\n"); + policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n"); + policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n"); + } + for port in &proxy.ports { + policy.push_str(&format!( + "(allow network-outbound (remote ip \"localhost:{port}\"))\n" + )); + } + let unix_socket_policy = unix_socket_policy(proxy); + if !unix_socket_policy.is_empty() { + policy.push_str("; allow unix domain sockets for local IPC\n"); + policy.push_str(&unix_socket_policy); + } + return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}"); + } + + if proxy.has_proxy_config { + // Proxy configuration is present but we could not infer any valid loopback endpoints. + // Fail closed to avoid silently widening network access in proxy-enforced sessions. + return String::new(); + } + + if enforce_managed_network { + // Managed network requirements are active but no usable proxy endpoints + // are available. Fail closed for network access. + return String::new(); + } + + if network_policy.is_enabled() { + // No proxy env is configured: retain the existing full-network behavior. + format!( + "(allow network-outbound)\n(allow network-inbound)\n{MACOS_SEATBELT_NETWORK_POLICY}" + ) + } else { + String::new() + } +} + +fn root_absolute_path() -> AbsolutePathBuf { + match AbsolutePathBuf::from_absolute_path(Path::new("/")) { + Ok(path) => path, + Err(err) => panic!("root path must be absolute: {err}"), + } +} + +#[derive(Debug, Clone)] +struct SeatbeltAccessRoot { + root: AbsolutePathBuf, + excluded_subpaths: Vec, +} + +fn build_seatbelt_access_policy( + action: &str, + param_prefix: &str, + roots: Vec, +) -> (String, Vec<(String, PathBuf)>) { + let mut policy_components = Vec::new(); + let mut params = Vec::new(); + + for (index, access_root) in roots.into_iter().enumerate() { + let root = + normalize_path_for_sandbox(access_root.root.as_path()).unwrap_or(access_root.root); + let root_param = format!("{param_prefix}_{index}"); + params.push((root_param.clone(), root.into_path_buf())); + + if access_root.excluded_subpaths.is_empty() { + policy_components.push(format!("(subpath (param \"{root_param}\"))")); + continue; + } + + let mut require_parts = vec![format!("(subpath (param \"{root_param}\"))")]; + for (excluded_index, excluded_subpath) in + access_root.excluded_subpaths.into_iter().enumerate() + { + let excluded_subpath = + normalize_path_for_sandbox(excluded_subpath.as_path()).unwrap_or(excluded_subpath); + let excluded_param = format!("{param_prefix}_{index}_RO_{excluded_index}"); + params.push((excluded_param.clone(), excluded_subpath.into_path_buf())); + require_parts.push(format!( + "(require-not (subpath (param \"{excluded_param}\")))" + )); + } + policy_components.push(format!("(require-all {} )", require_parts.join(" "))); + } + + if policy_components.is_empty() { + (String::new(), Vec::new()) + } else { + ( + format!("(allow {action}\n{}\n)", policy_components.join(" ")), + params, + ) + } +} + +#[cfg_attr(not(test), allow(dead_code))] +fn create_seatbelt_command_args_with_extensions( + command: Vec, + sandbox_policy: &SandboxPolicy, + sandbox_policy_cwd: &Path, + enforce_managed_network: bool, + network: Option<&NetworkProxy>, + extensions: Option<&MacOsSeatbeltProfileExtensions>, +) -> Vec { + create_seatbelt_command_args_for_policies_with_extensions( + command, + &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), + NetworkSandboxPolicy::from(sandbox_policy), + sandbox_policy_cwd, + enforce_managed_network, + network, + extensions, + ) +} + +pub fn create_seatbelt_command_args_for_policies_with_extensions( + command: Vec, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + enforce_managed_network: bool, + network: Option<&NetworkProxy>, + extensions: Option<&MacOsSeatbeltProfileExtensions>, +) -> Vec { + let unreadable_roots = + file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd); + let (file_write_policy, file_write_dir_params) = + if file_system_sandbox_policy.has_full_disk_write_access() { + if unreadable_roots.is_empty() { + // Allegedly, this is more permissive than `(allow file-write*)`. + ( + r#"(allow file-write* (regex #"^/"))"#.to_string(), + Vec::new(), + ) + } else { + build_seatbelt_access_policy( + "file-write*", + "WRITABLE_ROOT", + vec![SeatbeltAccessRoot { + root: root_absolute_path(), + excluded_subpaths: unreadable_roots.clone(), + }], + ) + } + } else { + build_seatbelt_access_policy( + "file-write*", + "WRITABLE_ROOT", + file_system_sandbox_policy + .get_writable_roots_with_cwd(sandbox_policy_cwd) + .into_iter() + .map(|root| SeatbeltAccessRoot { + root: root.root, + excluded_subpaths: root.read_only_subpaths, + }) + .collect(), + ) + }; + + let (file_read_policy, file_read_dir_params) = + if file_system_sandbox_policy.has_full_disk_read_access() { + if unreadable_roots.is_empty() { + ( + "; allow read-only file operations\n(allow file-read*)".to_string(), + Vec::new(), + ) + } else { + let (policy, params) = build_seatbelt_access_policy( + "file-read*", + "READABLE_ROOT", + vec![SeatbeltAccessRoot { + root: root_absolute_path(), + excluded_subpaths: unreadable_roots, + }], + ); + ( + format!("; allow read-only file operations\n{policy}"), + params, + ) + } + } else { + let (policy, params) = build_seatbelt_access_policy( + "file-read*", + "READABLE_ROOT", + file_system_sandbox_policy + .get_readable_roots_with_cwd(sandbox_policy_cwd) + .into_iter() + .map(|root| SeatbeltAccessRoot { + excluded_subpaths: unreadable_roots + .iter() + .filter(|path| path.as_path().starts_with(root.as_path())) + .cloned() + .collect(), + root, + }) + .collect(), + ); + if policy.is_empty() { + (String::new(), params) + } else { + ( + format!("; allow read-only file operations\n{policy}"), + params, + ) + } + }; + + let proxy = proxy_policy_inputs(network); + let network_policy = + dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy); + let seatbelt_extensions = extensions.map_or_else( + || { + // Backward-compatibility default when no extension profile is provided. + build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()) + }, + build_seatbelt_extensions, + ); + + let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults(); + let mut policy_sections = vec![ + MACOS_SEATBELT_BASE_POLICY.to_string(), + file_read_policy, + file_write_policy, + network_policy, + ]; + if include_platform_defaults { + policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string()); + } + if !seatbelt_extensions.policy.is_empty() { + policy_sections.push(seatbelt_extensions.policy.clone()); + } + + let full_policy = policy_sections.join("\n"); + + let dir_params = [ + file_read_dir_params, + file_write_dir_params, + macos_dir_params(), + unix_socket_dir_params(&proxy), + seatbelt_extensions.dir_params, + ] + .concat(); + + let mut seatbelt_args: Vec = vec!["-p".to_string(), full_policy]; + let definition_args = dir_params + .into_iter() + .map(|(key, value): (String, PathBuf)| { + format!("-D{key}={value}", value = value.to_string_lossy()) + }); + seatbelt_args.extend(definition_args); + seatbelt_args.push("--".to_string()); + seatbelt_args.extend(command); + seatbelt_args +} + +/// Wraps libc::confstr to return a String. +fn confstr(name: libc::c_int) -> Option { + let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1]; + let len = unsafe { libc::confstr(name, buf.as_mut_ptr(), buf.len()) }; + if len == 0 { + return None; + } + // confstr guarantees NUL-termination when len > 0. + let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) }; + cstr.to_str().ok().map(ToString::to_string) +} + +/// Wraps confstr to return a canonicalized PathBuf. +fn confstr_path(name: libc::c_int) -> Option { + let s = confstr(name)?; + let path = PathBuf::from(s); + path.canonicalize().ok().or(Some(path)) +} + +fn macos_dir_params() -> Vec<(String, PathBuf)> { + if let Some(p) = confstr_path(libc::_CS_DARWIN_USER_CACHE_DIR) { + return vec![("DARWIN_USER_CACHE_DIR".to_string(), p)]; + } + vec![] +} + +#[cfg(test)] +#[path = "seatbelt_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/seatbelt_base_policy.sbpl b/codex-rs/sandboxing/src/seatbelt_base_policy.sbpl similarity index 100% rename from codex-rs/core/src/seatbelt_base_policy.sbpl rename to codex-rs/sandboxing/src/seatbelt_base_policy.sbpl diff --git a/codex-rs/core/src/seatbelt_network_policy.sbpl b/codex-rs/sandboxing/src/seatbelt_network_policy.sbpl similarity index 100% rename from codex-rs/core/src/seatbelt_network_policy.sbpl rename to codex-rs/sandboxing/src/seatbelt_network_policy.sbpl diff --git a/codex-rs/core/src/seatbelt_permissions.rs b/codex-rs/sandboxing/src/seatbelt_permissions.rs similarity index 96% rename from codex-rs/core/src/seatbelt_permissions.rs rename to codex-rs/sandboxing/src/seatbelt_permissions.rs index 5cbfd65094..d53e7496ad 100644 --- a/codex-rs/core/src/seatbelt_permissions.rs +++ b/codex-rs/sandboxing/src/seatbelt_permissions.rs @@ -1,12 +1,10 @@ -#![cfg(target_os = "macos")] - use std::collections::BTreeSet; use std::path::PathBuf; -pub use codex_protocol::models::MacOsAutomationPermission; -pub use codex_protocol::models::MacOsContactsPermission; -pub use codex_protocol::models::MacOsPreferencesPermission; -pub use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsContactsPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) struct SeatbeltExtensionPolicy { diff --git a/codex-rs/core/src/seatbelt_permissions_tests.rs b/codex-rs/sandboxing/src/seatbelt_permissions_tests.rs similarity index 100% rename from codex-rs/core/src/seatbelt_permissions_tests.rs rename to codex-rs/sandboxing/src/seatbelt_permissions_tests.rs diff --git a/codex-rs/core/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs similarity index 94% rename from codex-rs/core/src/seatbelt_tests.rs rename to codex-rs/sandboxing/src/seatbelt_tests.rs index bab1362017..925285763e 100644 --- a/codex-rs/core/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -1,7 +1,7 @@ +use super::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use super::MACOS_SEATBELT_BASE_POLICY; use super::ProxyPolicyInputs; use super::UnixDomainSocketPolicy; -use super::create_seatbelt_command_args; use super::create_seatbelt_command_args_for_policies_with_extensions; use super::create_seatbelt_command_args_with_extensions; use super::dynamic_network_policy; @@ -9,18 +9,18 @@ use super::macos_dir_params; use super::normalize_path_for_sandbox; use super::unix_socket_dir_params; use super::unix_socket_policy; -use crate::protocol::ReadOnlyAccess; -use crate::protocol::SandboxPolicy; -use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; -use crate::seatbelt_permissions::MacOsAutomationPermission; -use crate::seatbelt_permissions::MacOsContactsPermission; -use crate::seatbelt_permissions::MacOsPreferencesPermission; -use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsContactsPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::fs; @@ -105,7 +105,7 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { - value: crate::protocol::FileSystemSpecialPath::Root, + value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Write, }, @@ -277,12 +277,13 @@ sys.exit(0 if allowed else 13) #[test] fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() { let cwd = std::env::temp_dir(); - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_with_extensions( vec!["echo".to_string(), "ok".to_string()], &SandboxPolicy::new_read_only_policy(), cwd.as_path(), - false, + /*enforce_managed_network*/ false, None, + /*extensions*/ None, ); let policy = &args[1]; assert!(policy.contains("(allow user-preference-read)")); @@ -295,7 +296,7 @@ fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { let cwd = tmp.path().join("workspace"); fs::create_dir_all(cwd.join("docs")).expect("create docs"); let docs = AbsolutePathBuf::from_absolute_path(cwd.join("docs")).expect("absolute docs"); - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_with_extensions( vec!["/bin/true".to_string()], &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], @@ -308,8 +309,9 @@ fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { exclude_slash_tmp: true, }, cwd.as_path(), - false, + /*enforce_managed_network*/ false, None, + /*extensions*/ None, ); let docs_param = format!("-DWRITABLE_ROOT_0_RO_0={}", docs.as_path().display()); @@ -636,7 +638,14 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, false, None); + let args = create_seatbelt_command_args_with_extensions( + shell_command.clone(), + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); // Build the expected policy text using a raw string for readability. // Note that the policy includes: @@ -740,8 +749,14 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let write_hooks_file_args = - create_seatbelt_command_args(shell_command_git, &policy, &cwd, false, None); + let write_hooks_file_args = create_seatbelt_command_args_with_extensions( + shell_command_git, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_hooks_file_args) .current_dir(&cwd) @@ -771,8 +786,14 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let write_allowed_file_args = - create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, false, None); + let write_allowed_file_args = create_seatbelt_command_args_with_extensions( + shell_command_allowed, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_allowed_file_args) .current_dir(&cwd) @@ -833,7 +854,14 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args(shell_command, &policy, &cwd, false, None); + let args = create_seatbelt_command_args_with_extensions( + shell_command, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&args) @@ -863,8 +891,14 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { .iter() .map(std::string::ToString::to_string) .collect(); - let gitdir_args = - create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd, false, None); + let gitdir_args = create_seatbelt_command_args_with_extensions( + shell_command_gitdir, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&gitdir_args) .current_dir(&cwd) @@ -921,12 +955,13 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_with_extensions( shell_command.clone(), &policy, vulnerable_root.as_path(), - false, + /*enforce_managed_network*/ false, None, + /*extensions*/ None, ); let tmpdir_env_var = std::env::var("TMPDIR")