From 7b34cad1b1ceb7cc8c4bea13b4a8c9a5ce6e3d9b Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 28 Jan 2026 14:36:40 -0700 Subject: [PATCH 01/70] fix(ci) more shell-tool-mcp issues (#10111) ## Summary More pnpm upgrade issues. --- shell-tool-mcp/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shell-tool-mcp/package.json b/shell-tool-mcp/package.json index d27c0a0c59..24798b2f6e 100644 --- a/shell-tool-mcp/package.json +++ b/shell-tool-mcp/package.json @@ -36,5 +36,6 @@ "ts-jest": "^29.3.4", "tsup": "^8.5.0", "typescript": "^5.9.2" - } + }, + "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" } From 83d7c44500561c70e1068009ff14df6e04ebe8b3 Mon Sep 17 00:00:00 2001 From: mjr-openai Date: Wed, 28 Jan 2026 18:30:48 -0500 Subject: [PATCH 02/70] update the ci pnpm workflow for shell-tool-mcp to use corepack for pnpm versioning (#10115) This updates the CI workflows for shell-tool-mcp to use the pnpm version from package.json and print it in the build for verification. I have read the CLA Document and I hereby sign the CLA --- .github/workflows/shell-tool-mcp-ci.yml | 9 +++++++++ .github/workflows/shell-tool-mcp.yml | 19 +++++++++++++++++-- package.json | 2 +- shell-tool-mcp/package.json | 1 + 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/workflows/shell-tool-mcp-ci.yml b/.github/workflows/shell-tool-mcp-ci.yml index 739cc6e409..bea88ad891 100644 --- a/.github/workflows/shell-tool-mcp-ci.yml +++ b/.github/workflows/shell-tool-mcp-ci.yml @@ -35,6 +35,15 @@ jobs: node-version: ${{ env.NODE_VERSION }} cache: "pnpm" + - name: Enable Corepack + run: corepack enable + + - name: Activate pnpm from package.json + run: corepack prepare --activate + + - name: Verify pnpm version + run: pnpm --version + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 88cdc28b3a..446af67918 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -351,6 +351,15 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} + - name: Enable Corepack + run: corepack enable + + - name: Activate pnpm from package.json + run: corepack prepare --activate + + - name: Verify pnpm version + run: pnpm --version + - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile @@ -448,8 +457,14 @@ jobs: registry-url: https://registry.npmjs.org scope: "@openai" - - name: Update npm - run: npm install -g npm@latest + - name: Enable Corepack + run: corepack enable + + - name: Activate pnpm from package.json + run: corepack prepare --activate + + - name: Verify pnpm version + run: pnpm --version - name: Download npm tarball uses: actions/download-artifact@v7 diff --git a/package.json b/package.json index 9f1452a972..633a6f8331 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "engines": { "node": ">=22", - "pnpm": ">=9.0.0" + "pnpm": ">=10.28.0" }, "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" } diff --git a/shell-tool-mcp/package.json b/shell-tool-mcp/package.json index 24798b2f6e..c6dfcd7a55 100644 --- a/shell-tool-mcp/package.json +++ b/shell-tool-mcp/package.json @@ -3,6 +3,7 @@ "version": "0.0.0-dev", "description": "Codex MCP server for the shell tool with patched Bash and exec wrappers.", "license": "Apache-2.0", + "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", "bin": { "codex-shell-tool-mcp": "bin/mcp-server.js" }, From 849716336347d7a8024ad75a514e11d0eacbc1cd Mon Sep 17 00:00:00 2001 From: zbarsky-openai Date: Wed, 28 Jan 2026 19:15:44 -0500 Subject: [PATCH 03/70] [bazel] Improve runfiles handling (#10098) we can't use runfiles directory on Windows due to path lengths, so swap to manifest strategy. Parsing the manifest is a bit complex and the format is changing in Bazel upstream, so pull in the official Rust library (via a small hack to make it importable...) and cleanup all the associated logic to work cleanly in both bazel and cargo without extra confusion --- .bazelignore | 1 + .bazelrc | 6 +- BUILD.bazel | 4 + MODULE.bazel | 7 + MODULE.bazel.lock | 4 +- codex-rs/Cargo.lock | 7 +- codex-rs/Cargo.toml | 1 + codex-rs/core/tests/suite/cli_stream.rs | 2 +- codex-rs/exec/tests/suite/auth_env.rs | 3 +- codex-rs/exec/tests/suite/resume.rs | 2 +- codex-rs/utils/cargo-bin/BUILD.bazel | 6 + codex-rs/utils/cargo-bin/Cargo.toml | 2 +- codex-rs/utils/cargo-bin/README.md | 20 ++ codex-rs/utils/cargo-bin/repo_root.marker | 1 + codex-rs/utils/cargo-bin/src/lib.rs | 227 +++++++++++++--------- defs.bzl | 11 +- patches/rules_rust.patch | 2 +- 17 files changed, 203 insertions(+), 103 deletions(-) create mode 100644 codex-rs/utils/cargo-bin/README.md create mode 100644 codex-rs/utils/cargo-bin/repo_root.marker diff --git a/.bazelignore b/.bazelignore index eda018aeb2..2e90753cb8 100644 --- a/.bazelignore +++ b/.bazelignore @@ -1,3 +1,4 @@ # Without this, Bazel will consider BUILD.bazel files in # .git/sl/origbackups (which can be populated by Sapling SCM). .git +codex-rs/target diff --git a/.bazelrc b/.bazelrc index 764754c216..ce0c2ee5d5 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,5 +1,7 @@ common --repo_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1 common --repo_env=BAZEL_NO_APPLE_CPP_TOOLCHAIN=1 +# Dummy xcode config so we don't need to build xcode_locator in repo rule. +common --xcode_version_config=//:disable_xcode common --disk_cache=~/.cache/bazel-disk-cache common --repo_contents_cache=~/.cache/bazel-repo-contents-cache @@ -9,6 +11,9 @@ startup --experimental_remote_repo_contents_cache common --experimental_platform_in_output_dir +# Runfiles strategy rationale: codex-rs/utils/cargo-bin/README.md +common --noenable_runfiles + common --enable_platform_specific_config # TODO(zbarsky): We need to untangle these libc constraints to get linux remote builds working. common:linux --host_platform=//:local @@ -44,4 +49,3 @@ common --jobs=30 common:remote --extra_execution_platforms=//:rbe common:remote --remote_executor=grpcs://remote.buildbuddy.io common:remote --jobs=800 - diff --git a/BUILD.bazel b/BUILD.bazel index 883432655c..dc57103b6b 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -1,3 +1,7 @@ +load("@apple_support//xcode:xcode_config.bzl", "xcode_config") + +xcode_config(name = "disable_xcode") + # We mark the local platform as glibc-compatible so that rust can grab a toolchain for us. # TODO(zbarsky): Upstream a better libc constraint into rules_rust. # We only enable this on linux though for sanity, and because it breaks remote execution. diff --git a/MODULE.bazel b/MODULE.bazel index df0df9b25c..f4c593b3f3 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -27,6 +27,8 @@ register_toolchains( "@toolchains_llvm_bootstrapped//toolchain:all", ) +# Needed to disable xcode... +bazel_dep(name = "apple_support", version = "2.1.0") bazel_dep(name = "rules_cc", version = "0.2.16") bazel_dep(name = "rules_platform", version = "0.1.0") bazel_dep(name = "rules_rust", version = "0.68.1") @@ -90,6 +92,11 @@ crate.annotation( inject_repo(crate, "openssl") +crate.annotation( + crate = "runfiles", + workspace_cargo_toml = "rust/runfiles/Cargo.toml", +) + # Fix readme inclusions crate.annotation( crate = "windows-link", diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7444d3fdbe..63b7ed16ee 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -21,7 +21,8 @@ "https://bcr.bazel.build/modules/apple_support/1.23.0/MODULE.bazel": "317d47e3f65b580e7fb4221c160797fda48e32f07d2dfff63d754ef2316dcd25", "https://bcr.bazel.build/modules/apple_support/1.24.1/MODULE.bazel": "f46e8ddad60aef170ee92b2f3d00ef66c147ceafea68b6877cb45bd91737f5f8", "https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4", - "https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d", + "https://bcr.bazel.build/modules/apple_support/2.1.0/MODULE.bazel": "b15c125dabed01b6803c129cd384de4997759f02f8ec90dc5136bcf6dfc5086a", + "https://bcr.bazel.build/modules/apple_support/2.1.0/source.json": "78064cfefe18dee4faaf51893661e0d403784f3efe88671d727cdcdc67ed8fb3", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456", @@ -790,6 +791,7 @@ "gimli_0.31.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"fallible-iterator\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"test-assembler\",\"req\":\"^0.1.3\"}],\"features\":{\"default\":[\"read-all\",\"write\"],\"endian-reader\":[\"read\",\"dep:stable_deref_trait\"],\"fallible-iterator\":[\"dep:fallible-iterator\"],\"read\":[\"read-core\"],\"read-all\":[\"read\",\"std\",\"fallible-iterator\",\"endian-reader\"],\"read-core\":[],\"rustc-dep-of-std\":[\"dep:core\",\"dep:alloc\",\"dep:compiler_builtins\"],\"std\":[\"fallible-iterator?/std\",\"stable_deref_trait?/std\"],\"write\":[\"dep:indexmap\"]}}", "git+https://github.com/JakkuSakura/tokio-tungstenite?rev=2ae536b0de793f3ddf31fc2f22d445bf1ef2023d#2ae536b0de793f3ddf31fc2f22d445bf1ef2023d_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/JakkuSakura/tungstenite-rs?rev=f514de8644821113e5d18a027d6d28a5c8cc0a6e#f514de8644821113e5d18a027d6d28a5c8cc0a6e_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81_runfiles": "{\"dependencies\":[],\"features\":{},\"strip_prefix\":\"\"}", "git+https://github.com/helix-editor/nucleo.git?rev=4253de9faabb4e5c6d81d946a5e35a90f87347ee#4253de9faabb4e5c6d81d946a5e35a90f87347ee_nucleo": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"nucleo-matcher\",\"optional\":false},{\"default_features\":true,\"features\":[\"send_guard\",\"arc_lock\"],\"name\":\"parking_lot\",\"optional\":false},{\"name\":\"rayon\"}],\"features\":{},\"strip_prefix\":\"\"}", "git+https://github.com/helix-editor/nucleo.git?rev=4253de9faabb4e5c6d81d946a5e35a90f87347ee#4253de9faabb4e5c6d81d946a5e35a90f87347ee_nucleo-matcher": "{\"dependencies\":[{\"name\":\"memchr\"},{\"default_features\":true,\"features\":[],\"name\":\"unicode-segmentation\",\"optional\":true}],\"features\":{\"default\":[\"unicode-normalization\",\"unicode-casefold\",\"unicode-segmentation\"],\"unicode-casefold\":[],\"unicode-normalization\":[],\"unicode-segmentation\":[\"dep:unicode-segmentation\"]},\"strip_prefix\":\"matcher\"}", "git+https://github.com/nornagon/crossterm?branch=nornagon%2Fcolor-query#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 38d44c44b5..c6b006c203 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1975,7 +1975,7 @@ name = "codex-utils-cargo-bin" version = "0.0.0" dependencies = [ "assert_cmd", - "path-absolutize", + "runfiles", "thiserror 2.0.17", ] @@ -6592,6 +6592,11 @@ dependencies = [ "zeroize", ] +[[package]] +name = "runfiles" +version = "0.1.0" +source = "git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81" + [[package]] name = "rustc-demangle" version = "0.1.25" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 9b0ce2e16d..5196213991 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -186,6 +186,7 @@ regex = "1.12.2" regex-lite = "0.1.8" reqwest = "0.12" rmcp = { version = "0.12.0", default-features = false } +runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } schemars = "0.8.22" seccompiler = "0.5.0" sentry = "0.46.0" diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 57142b07f2..9ca2c6bd68 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -16,7 +16,7 @@ use wiremock::matchers::path; fn repo_root() -> std::path::PathBuf { #[expect(clippy::expect_used)] - find_resource!(".").expect("failed to resolve repo root") + codex_utils_cargo_bin::repo_root().expect("failed to resolve repo root") } fn cli_responses_fixture() -> std::path::PathBuf { diff --git a/codex-rs/exec/tests/suite/auth_env.rs b/codex-rs/exec/tests/suite/auth_env.rs index 4f8018e808..d55da946e2 100644 --- a/codex-rs/exec/tests/suite/auth_env.rs +++ b/codex-rs/exec/tests/suite/auth_env.rs @@ -1,5 +1,4 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] -use codex_utils_cargo_bin::find_resource; use core_test_support::responses::ev_completed; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; @@ -11,7 +10,7 @@ use wiremock::matchers::header; async fn exec_uses_codex_api_key_env_var() -> anyhow::Result<()> { let test = test_codex_exec(); let server = start_mock_server().await; - let repo_root = find_resource!(".")?; + let repo_root = codex_utils_cargo_bin::repo_root()?; mount_sse_once_match( &server, diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index 6e24934f21..4169c60dd1 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -113,7 +113,7 @@ fn exec_fixture() -> anyhow::Result { } fn exec_repo_root() -> anyhow::Result { - Ok(find_resource!(".")?) + Ok(codex_utils_cargo_bin::repo_root()?) } #[test] diff --git a/codex-rs/utils/cargo-bin/BUILD.bazel b/codex-rs/utils/cargo-bin/BUILD.bazel index 77d6518012..b5b5e6ba33 100644 --- a/codex-rs/utils/cargo-bin/BUILD.bazel +++ b/codex-rs/utils/cargo-bin/BUILD.bazel @@ -3,4 +3,10 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "cargo-bin", crate_name = "codex_utils_cargo_bin", + compile_data = ["repo_root.marker"], + lib_data_extra = ["repo_root.marker"], + test_data_extra = ["repo_root.marker"], + rustc_env = { + "CODEX_REPO_ROOT_MARKER": "$(rlocationpath :repo_root.marker)", + }, ) diff --git a/codex-rs/utils/cargo-bin/Cargo.toml b/codex-rs/utils/cargo-bin/Cargo.toml index fe3a410547..6cbe923f97 100644 --- a/codex-rs/utils/cargo-bin/Cargo.toml +++ b/codex-rs/utils/cargo-bin/Cargo.toml @@ -9,5 +9,5 @@ workspace = true [dependencies] assert_cmd = { workspace = true } -path-absolutize = { workspace = true } +runfiles = { workspace = true } thiserror = { workspace = true } diff --git a/codex-rs/utils/cargo-bin/README.md b/codex-rs/utils/cargo-bin/README.md new file mode 100644 index 0000000000..05af2f34bf --- /dev/null +++ b/codex-rs/utils/cargo-bin/README.md @@ -0,0 +1,20 @@ +# codex-utils-cargo-bin runfiles strategy + +We disable the directory-based runfiles strategy and rely on the manifest +strategy across all platforms. This avoids Windows path length issues and keeps +behavior consistent in local and remote builds on all platforms. Bazel sets +`RUNFILES_MANIFEST_FILE`, and the `codex-utils-cargo-bin` helpers use the +`runfiles` crate to resolve runfiles via that manifest. + +Function behavior: +- `cargo_bin`: reads `CARGO_BIN_EXE_*` environment variables (set by Cargo or + Bazel) and resolves them via the runfiles manifest when `RUNFILES_MANIFEST_FILE` + is present. When not under runfiles, it only accepts absolute paths from + `CARGO_BIN_EXE_*` and returns an error otherwise. +- `find_resource!`: used by tests to locate fixtures. It chooses the Bazel + runfiles resolution path when `RUNFILES_MANIFEST_FILE` is set, otherwise it + falls back to a `CARGO_MANIFEST_DIR`-relative path for Cargo runs. + +Background: +- https://bazel.build/docs/runfiles +- https://bazel.build/docs/runfiles#runfiles-manifest diff --git a/codex-rs/utils/cargo-bin/repo_root.marker b/codex-rs/utils/cargo-bin/repo_root.marker new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/codex-rs/utils/cargo-bin/repo_root.marker @@ -0,0 +1 @@ + diff --git a/codex-rs/utils/cargo-bin/src/lib.rs b/codex-rs/utils/cargo-bin/src/lib.rs index 40fa40c62f..84f69af358 100644 --- a/codex-rs/utils/cargo-bin/src/lib.rs +++ b/codex-rs/utils/cargo-bin/src/lib.rs @@ -1,7 +1,12 @@ use std::ffi::OsString; +use std::io; +use std::path::Path; use std::path::PathBuf; -pub use path_absolutize; +pub use runfiles; + +/// Bazel sets this when runfiles directories are disabled, which we do on all platforms for consistency. +const RUNFILES_MANIFEST_ONLY_ENV: &str = "RUNFILES_MANIFEST_ONLY"; #[derive(Debug, thiserror::Error)] pub enum CargoBinError { @@ -27,10 +32,9 @@ pub enum CargoBinError { /// Returns an absolute path to a binary target built for the current test run. /// -/// In `cargo test`, `CARGO_BIN_EXE_*` env vars are absolute, but Buck2 may set -/// them to project-relative paths (e.g. `buck-out/...`). Those paths break if a -/// test later changes its working directory. This helper makes the path -/// absolute up-front so callers can safely `chdir` afterwards. +/// In `cargo test`, `CARGO_BIN_EXE_*` env vars are absolute. +/// In `bazel test`, `CARGO_BIN_EXE_*` env vars are rlocationpaths, intended to be consumed by `rlocation`. +/// This helper allows callers to transparently support both. pub fn cargo_bin(name: &str) -> Result { let env_keys = cargo_bin_env_keys(name); for key in &env_keys { @@ -38,16 +42,20 @@ pub fn cargo_bin(name: &str) -> Result { return resolve_bin_from_env(key, value); } } - match assert_cmd::Command::cargo_bin(name) { Ok(cmd) => { - let abs = absolutize_from_buck_or_cwd(PathBuf::from(cmd.get_program()))?; - if abs.exists() { - Ok(abs) + let mut path = PathBuf::from(cmd.get_program()); + if !path.is_absolute() { + path = std::env::current_dir() + .map_err(|source| CargoBinError::CurrentDir { source })? + .join(path); + } + if path.exists() { + Ok(path) } else { Err(CargoBinError::ResolvedPathDoesNotExist { key: "assert_cmd::Command::cargo_bin".to_owned(), - path: abs, + path, }) } } @@ -72,6 +80,31 @@ fn cargo_bin_env_keys(name: &str) -> Vec { keys } +pub fn runfiles_available() -> bool { + std::env::var_os(RUNFILES_MANIFEST_ONLY_ENV).is_some() +} + +fn resolve_bin_from_env(key: &str, value: OsString) -> Result { + let raw = PathBuf::from(&value); + if runfiles_available() { + let runfiles = runfiles::Runfiles::create().map_err(|err| CargoBinError::CurrentExe { + source: std::io::Error::other(err), + })?; + if let Some(resolved) = runfiles::rlocation!(runfiles, &raw) + && resolved.exists() + { + return Ok(resolved); + } + } else if raw.is_absolute() && raw.exists() { + return Ok(raw); + } + + Err(CargoBinError::ResolvedPathDoesNotExist { + key: key.to_owned(), + path: raw, + }) +} + /// Macro that derives the path to a test resource at runtime, the value of /// which depends on whether Cargo or Bazel is being used to build and run a /// test. Note the return value may be a relative or absolute path. @@ -84,97 +117,109 @@ fn cargo_bin_env_keys(name: &str) -> Vec { #[macro_export] macro_rules! find_resource { ($resource:expr) => {{ - // When this code is built and run with Bazel: - // - we inject `BAZEL_PACKAGE` as a compile-time environment variable - // that points to native.package_name() - // - at runtime, Bazel will set `RUNFILES_DIR` to the runfiles directory - // - // Therefore, the compile-time value of `BAZEL_PACKAGE` will always be - // included in the compiled binary (even if it is built with Cargo), but - // we only check it at runtime if `RUNFILES_DIR` is set. let resource = std::path::Path::new(&$resource); - match std::env::var("RUNFILES_DIR") { - Ok(bazel_runtime_files) => match option_env!("BAZEL_PACKAGE") { - Some(bazel_package) => { - use $crate::path_absolutize::Absolutize; - - let manifest_dir = std::path::PathBuf::from(bazel_runtime_files) - .join("_main") - .join(bazel_package) - .join(resource); - // Note we also have to normalize (but not canonicalize!) - // the path for _Bazel_ because the original value ends with - // `codex-rs/exec-server/tests/common/../suite/bash`, but - // the `tests/common` folder will not exist at runtime under - // Bazel. As such, we have to normalize it before passing it - // to `dotslash fetch`. - manifest_dir.absolutize().map(|p| p.to_path_buf()) - } - None => Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "BAZEL_PACKAGE not set in Bazel build", - )), - }, - Err(_) => { - let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - Ok(manifest_dir.join(resource)) - } + if $crate::runfiles_available() { + // When this code is built and run with Bazel: + // - we inject `BAZEL_PACKAGE` as a compile-time environment variable + // that points to native.package_name() + // - at runtime, Bazel will set runfiles-related env vars + $crate::resolve_bazel_runfile(option_env!("BAZEL_PACKAGE"), resource) + } else { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + Ok(manifest_dir.join(resource)) } }}; } -fn resolve_bin_from_env(key: &str, value: OsString) -> Result { - let abs = absolutize_from_buck_or_cwd(PathBuf::from(value))?; - - if abs.exists() { - Ok(abs) - } else { - Err(CargoBinError::ResolvedPathDoesNotExist { - key: key.to_owned(), - path: abs, - }) - } -} - -fn absolutize_from_buck_or_cwd(path: PathBuf) -> Result { - if path.is_absolute() { - return Ok(path); - } - - if let Some(root) = - buck_project_root().map_err(|source| CargoBinError::CurrentExe { source })? +pub fn resolve_bazel_runfile( + bazel_package: Option<&str>, + resource: &Path, +) -> std::io::Result { + let runfiles = runfiles::Runfiles::create() + .map_err(|err| std::io::Error::other(format!("failed to create runfiles: {err}")))?; + let runfile_path = match bazel_package { + Some(bazel_package) => PathBuf::from("_main").join(bazel_package).join(resource), + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "BAZEL_PACKAGE was not set at compile time", + )); + } + }; + let runfile_path = normalize_runfile_path(&runfile_path); + if let Some(resolved) = runfiles::rlocation!(runfiles, &runfile_path) + && resolved.exists() { - return Ok(root.join(path)); + return Ok(resolved); } - - Ok(std::env::current_dir() - .map_err(|source| CargoBinError::CurrentDir { source })? - .join(path)) + let runfile_path_display = runfile_path.display(); + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("runfile does not exist at: {runfile_path_display}"), + )) } -/// Best-effort attempt to find the Buck project root for the currently running -/// process. -/// -/// Prefer this over `env!("CARGO_MANIFEST_DIR")` when running under Buck2: our -/// Buck generator sets `CARGO_MANIFEST_DIR="."` for compilation, which makes -/// `env!("CARGO_MANIFEST_DIR")` unusable for locating workspace files. -pub fn buck_project_root() -> Result, std::io::Error> { - if let Some(root) = std::env::var_os("BUCK_PROJECT_ROOT") { - let root = PathBuf::from(root); - if root.is_absolute() { - return Ok(Some(root)); +pub fn resolve_cargo_runfile(resource: &Path) -> std::io::Result { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + Ok(manifest_dir.join(resource)) +} + +pub fn repo_root() -> io::Result { + let marker = if runfiles_available() { + let runfiles = runfiles::Runfiles::create() + .map_err(|err| io::Error::other(format!("failed to create runfiles: {err}")))?; + let marker_path = option_env!("CODEX_REPO_ROOT_MARKER") + .map(PathBuf::from) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "CODEX_REPO_ROOT_MARKER was not set at compile time", + ) + })?; + runfiles::rlocation!(runfiles, &marker_path).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "repo_root.marker not available in runfiles", + ) + })? + } else { + resolve_cargo_runfile(Path::new("repo_root.marker"))? + }; + let mut root = marker; + for _ in 0..4 { + root = root + .parent() + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "repo_root.marker did not have expected parent depth", + ) + })? + .to_path_buf(); + } + Ok(root) +} + +fn normalize_runfile_path(path: &Path) -> PathBuf { + let mut components = Vec::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if matches!(components.last(), Some(std::path::Component::Normal(_))) { + components.pop(); + } else { + components.push(component); + } + } + _ => components.push(component), } } - // Fall back to deriving the project root from the location of the test - // runner executable: - // /buck-out/v2/gen/.../__tests__/test-binary - let exe = std::env::current_exe()?; - for ancestor in exe.ancestors() { - if ancestor.file_name().is_some_and(|name| name == "buck-out") { - return Ok(ancestor.parent().map(PathBuf::from)); - } - } - - Ok(None) + components + .into_iter() + .fold(PathBuf::new(), |mut acc, component| { + acc.push(component.as_os_str()); + acc + }) } diff --git a/defs.bzl b/defs.bzl index 249c3a4583..40112969c8 100644 --- a/defs.bzl +++ b/defs.bzl @@ -36,6 +36,8 @@ def codex_rust_crate( crate_edition = None, build_script_data = [], compile_data = [], + lib_data_extra = [], + rustc_env = {}, deps_extra = [], integration_deps_extra = [], integration_compile_data_extra = [], @@ -63,6 +65,8 @@ def codex_rust_crate( You probably don't want this, it's only here for a single caller. build_script_data: Data files exposed to the build script at runtime. compile_data: Non-Rust compile-time data for the library target. + lib_data_extra: Extra runtime data for the library target. + rustc_env: Extra rustc_env entries to merge with defaults. deps_extra: Extra normal deps beyond @crates resolution. Typically only needed when features add additional deps. integration_deps_extra: Extra deps for integration tests only. @@ -85,7 +89,7 @@ def codex_rust_crate( rustc_env = { "BAZEL_PACKAGE": native.package_name(), - } + } | rustc_env binaries = DEP_DATA.get(native.package_name())["binaries"] @@ -112,6 +116,7 @@ def codex_rust_crate( deps = deps, proc_macro_deps = proc_macro_deps, compile_data = compile_data, + data = lib_data_extra, srcs = lib_srcs, edition = crate_edition, rustc_env = rustc_env, @@ -138,7 +143,7 @@ def codex_rust_crate( for binary, main in binaries.items(): #binary = binary.replace("-", "_") sanitized_binaries.append(binary) - cargo_env["CARGO_BIN_EXE_" + binary] = "$(rootpath :%s)" % binary + cargo_env["CARGO_BIN_EXE_" + binary] = "$(rlocationpath :%s)" % binary rust_binary( name = binary, @@ -154,7 +159,7 @@ def codex_rust_crate( for binary_label in extra_binaries: sanitized_binaries.append(binary_label) binary = Label(binary_label).name - cargo_env["CARGO_BIN_EXE_" + binary] = "$(rootpath %s)" % binary_label + cargo_env["CARGO_BIN_EXE_" + binary] = "$(rlocationpath %s)" % binary_label for test in native.glob(["tests/*.rs"], allow_empty = True): test_name = name + "-" + test.removeprefix("tests/").removesuffix(".rs").replace("/", "-") diff --git a/patches/rules_rust.patch b/patches/rules_rust.patch index 298eb74c51..3020ac2282 100644 --- a/patches/rules_rust.patch +++ b/patches/rules_rust.patch @@ -31,4 +31,4 @@ index a28ad50b7..af627fe50 100644 + make_link_flags = _make_link_flags_default_direct if use_direct_link_driver else _make_link_flags_default_indirect return (make_link_flags, get_lib_name) - + From 26590d7927c0ca6f12c5ad73fdaeaa9ad73a2932 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 28 Jan 2026 16:51:20 -0800 Subject: [PATCH 04/70] Ensure auto-compaction starts after turn started (#10129) Start auto-compaction only after TurnStarted is emitted.\nAdd an integration test for deterministic ordering. --- codex-rs/core/src/codex.rs | 6 +- codex-rs/core/tests/suite/compact.rs | 95 ++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c251805272..70e52eb064 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3151,13 +3151,13 @@ pub(crate) async fn run_turn( let model_info = turn_context.client.get_model_info(); let auto_compact_limit = model_info.auto_compact_token_limit().unwrap_or(i64::MAX); let total_usage_tokens = sess.get_total_token_usage().await; - if total_usage_tokens >= auto_compact_limit { - run_auto_compact(&sess, &turn_context).await; - } let event = EventMsg::TurnStarted(TurnStartedEvent { model_context_window: turn_context.client.get_model_context_window(), }); sess.send_event(&turn_context, event).await; + if total_usage_tokens >= auto_compact_limit { + run_auto_compact(&sess, &turn_context).await; + } let skills_outcome = Some( sess.services diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index b8b693945b..fa07bf7f9b 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1339,6 +1339,101 @@ async fn auto_compact_emits_context_compaction_items() { assert!(legacy_event); } +// Windows CI only: bump to 4 workers to prevent SSE/event starvation and test timeouts. +#[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))] +#[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))] +async fn auto_compact_starts_after_turn_started() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let sse1 = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_tokens("r1", 70_000), + ]); + let sse2 = sse(vec![ + ev_assistant_message("m2", "SECOND_REPLY"), + ev_completed_with_tokens("r2", 330_000), + ]); + let sse3 = sse(vec![ + ev_assistant_message("m3", AUTO_SUMMARY_TEXT), + ev_completed_with_tokens("r3", 200), + ]); + let sse4 = sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_tokens("r4", 120), + ]); + + mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await; + + let model_provider = non_openai_model_provider(&server); + let mut builder = test_codex().with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200_000); + }); + let codex = builder.build(&server).await.unwrap().codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: FIRST_AUTO_MSG.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: SECOND_AUTO_MSG.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: POST_AUTO_USER_MSG.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + let first = wait_for_event_match(&codex, |ev| match ev { + EventMsg::TurnStarted(_) => Some("turn"), + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::ContextCompaction(_), + .. + }) => Some("compaction"), + _ => None, + }) + .await; + assert_eq!(first, "turn", "compaction started before turn started"); + + wait_for_event(&codex, |ev| { + matches!( + ev, + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::ContextCompaction(_), + .. + }) + ) + }) + .await; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { skip_if_no_network!(); From ce3d764ae103404c82059cfa359275022ac013c4 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 28 Jan 2026 17:58:28 -0700 Subject: [PATCH 05/70] chore(config) personality as a feature (#10116) ## Summary Sets up an explicit Feature flag for `/personality`, so users can now opt in to it via `/experimental`. #10114 also updates the config ## Testing - [x] Tested locally --- codex-rs/core/config.schema.json | 6 ++++ codex-rs/core/src/features.rs | 12 +++++++ .../core/src/models_manager/model_info.rs | 10 ++++++ codex-rs/tui/src/chatwidget.rs | 32 +++++++++++-------- ...t__tests__personality_selection_popup.snap | 6 ++-- codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/slash_command.rs | 2 +- 7 files changed, 51 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 03e27dceda..ea6ec9e872 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -180,6 +180,9 @@ "include_apply_patch_tool": { "type": "boolean" }, + "personality": { + "type": "boolean" + }, "powershell_utf8": { "type": "boolean" }, @@ -1198,6 +1201,9 @@ "include_apply_patch_tool": { "type": "boolean" }, + "personality": { + "type": "boolean" + }, "powershell_utf8": { "type": "boolean" }, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 26798c29d9..bedf67c4ad 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -119,6 +119,8 @@ pub enum Feature { Steer, /// Enable collaboration modes (Plan, Code, Pair Programming, Execute). CollaborationModes, + /// Enable personality selection in the TUI. + Personality, /// Use the Responses API WebSocket transport for OpenAI by default. ResponsesWebsockets, } @@ -543,6 +545,16 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::Personality, + key: "personality", + stage: Stage::Experimental { + name: "Personality", + menu_description: "Choose a communication style for Codex.", + announcement: "NEW! Update codex's communication style with /personality. Enable in /experimental!", + }, + default_enabled: false, + }, FeatureSpec { id: Feature::ResponsesWebsockets, key: "responses_websockets", diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 5d8d0d2937..b50f23274a 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -213,6 +213,16 @@ pub(crate) fn find_model_info_for_slug(slug: &str) -> ModelInfo { truncation_policy: TruncationPolicyConfig::tokens(10_000), context_window: Some(CONTEXT_WINDOW_272K), supported_reasoning_levels: supported_reasoning_level_low_medium_high_xhigh(), + model_instructions_template: Some(ModelInstructionsTemplate { + template: GPT_5_2_CODEX_INSTRUCTIONS_TEMPLATE.to_string(), + personality_messages: Some(PersonalityMessages(BTreeMap::from([( + Personality::Friendly, + PERSONALITY_FRIENDLY.to_string(), + ), ( + Personality::Pragmatic, + PERSONALITY_PRAGMATIC.to_string(), + )]))), + }), ) } else if slug.starts_with("gpt-5.1-codex-max") { model_info!( diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bd1014b2f4..dcbb4d7c22 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2892,6 +2892,7 @@ impl ChatWidget { let personality = self .config .model_personality + .filter(|_| self.config.features.enabled(Feature::Personality)) .filter(|_| self.current_model_supports_personality()); let op = Op::UserTurn { items, @@ -3455,19 +3456,23 @@ impl ChatWidget { ); return; } + if !self.current_model_supports_personality() { + let current_model = self.current_model(); + self.add_error_message(format!( + "Current model ({current_model}) doesn't support personalities. Try /model to pick a different model." + )); + return; + } self.open_personality_popup_for_current_model(); } fn open_personality_popup_for_current_model(&mut self) { - let current_model = self.current_model(); - let current_personality = self.config.model_personality; + let current_personality = self + .config + .model_personality + .unwrap_or(Personality::Friendly); let personalities = [Personality::Friendly, Personality::Pragmatic]; let supports_personality = self.current_model_supports_personality(); - let disabled_message = (!supports_personality).then(|| { - format!( - "Current model ({current_model}) doesn't support personalities. Try /model to switch to a newer model." - ) - }); let items: Vec = personalities .into_iter() @@ -3492,7 +3497,7 @@ impl ChatWidget { SelectionItem { name, description, - is_current: current_personality == Some(personality), + is_current: current_personality == personality, is_disabled: !supports_personality, actions, dismiss_on_select: true, @@ -3504,11 +3509,8 @@ impl ChatWidget { let mut header = ColumnRenderable::new(); header.push(Line::from("Select Personality".bold())); header.push(Line::from( - "Choose a communication style for future responses.".dim(), + "Choose a communication style for Codex. Disable in /experimental.".dim(), )); - if let Some(message) = disabled_message { - header.push(Line::from(message.red())); - } self.bottom_pane.show_selection_view(SelectionViewParams { header: Box::new(header), @@ -4714,6 +4716,9 @@ impl ChatWidget { self.refresh_model_display(); self.request_redraw(); } + if feature == Feature::Personality { + self.sync_personality_command_enabled(); + } #[cfg(target_os = "windows")] if matches!( feature, @@ -4780,7 +4785,6 @@ impl ChatWidget { mask.model = Some(model.to_string()); } self.refresh_model_display(); - self.sync_personality_command_enabled(); } pub(crate) fn current_model(&self) -> &str { @@ -4795,7 +4799,7 @@ impl ChatWidget { fn sync_personality_command_enabled(&mut self) { self.bottom_pane - .set_personality_command_enabled(self.current_model_supports_personality()); + .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); } fn current_model_supports_personality(&self) -> bool { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap index 6df7d5ad6b..3c6bba94e6 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap @@ -3,9 +3,9 @@ source: tui/src/chatwidget/tests.rs expression: popup --- Select Personality - Choose a communication style for future responses. + Choose a communication style for Codex. Disable in /experimental. -› 1. Friendly Warm, collaborative, and helpful. - 2. Pragmatic Concise, task-focused, and direct. +› 1. Friendly (current) Warm, collaborative, and helpful. + 2. Pragmatic Concise, task-focused, and direct. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 64060aece8..78fbc15b49 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2408,6 +2408,7 @@ async fn collab_mode_enabling_keeps_custom_until_selected() { #[tokio::test] async fn user_turn_includes_personality_from_config() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("bengalfox")).await; + chat.set_feature_enabled(Feature::Personality, true); chat.thread_id = Some(ThreadId::new()); chat.set_model("bengalfox"); chat.set_personality(Personality::Friendly); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 806cc39e04..8a7a320665 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -61,7 +61,7 @@ impl SlashCommand { SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Ps => "list background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", - SlashCommand::Personality => "choose a communication style for responses", + SlashCommand::Personality => "choose a communication style for Codex", SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Agent => "switch the active agent thread", SlashCommand::Approvals => "choose what Codex can do without approval", From 52609c6f42916a67eec83e8780971e09c1c83092 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 28 Jan 2026 17:00:38 -0800 Subject: [PATCH 06/70] Add app-server compaction item notifications tests (#10123) - add v2 tests covering local + remote auto-compaction item started/completed notifications --- codex-rs/app-server/tests/common/config.rs | 72 +++++ codex-rs/app-server/tests/common/lib.rs | 2 + .../app-server/tests/suite/v2/compaction.rs | 282 ++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + 4 files changed, 357 insertions(+) create mode 100644 codex-rs/app-server/tests/common/config.rs create mode 100644 codex-rs/app-server/tests/suite/v2/compaction.rs diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs new file mode 100644 index 0000000000..09471b4a69 --- /dev/null +++ b/codex-rs/app-server/tests/common/config.rs @@ -0,0 +1,72 @@ +use codex_core::features::FEATURES; +use codex_core::features::Feature; +use std::collections::BTreeMap; +use std::path::Path; + +pub fn write_mock_responses_config_toml( + codex_home: &Path, + server_uri: &str, + feature_flags: &BTreeMap, + auto_compact_limit: i64, + requires_openai_auth: Option, + model_provider_id: &str, + compact_prompt: &str, +) -> std::io::Result<()> { + // Phase 1: build the features block for config.toml. + let mut features = BTreeMap::from([(Feature::RemoteModels, false)]); + for (feature, enabled) in feature_flags { + features.insert(*feature, *enabled); + } + let feature_entries = features + .into_iter() + .map(|(feature, enabled)| { + let key = FEATURES + .iter() + .find(|spec| spec.id == feature) + .map(|spec| spec.key) + .unwrap_or_else(|| panic!("missing feature key for {feature:?}")); + format!("{key} = {enabled}") + }) + .collect::>() + .join("\n"); + // Phase 2: build provider-specific config bits. + let requires_line = match requires_openai_auth { + Some(true) => "requires_openai_auth = true\n".to_string(), + Some(false) | None => String::new(), + }; + let provider_block = if model_provider_id == "openai" { + String::new() + } else { + format!( + r#" +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +{requires_line} +"# + ) + }; + // Phase 3: write the final config file. + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +compact_prompt = "{compact_prompt}" +model_auto_compact_token_limit = {auto_compact_limit} + +model_provider = "{model_provider_id}" + +[features] +{feature_entries} +{provider_block} +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 12bd4049b4..4a2a99db23 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -1,4 +1,5 @@ mod auth_fixtures; +mod config; mod mcp_process; mod mock_model_server; mod models_cache; @@ -10,6 +11,7 @@ pub use auth_fixtures::ChatGptIdTokenClaims; pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; +pub use config::write_mock_responses_config_toml; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs new file mode 100644 index 0000000000..66cf43bc0a --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -0,0 +1,282 @@ +//! End-to-end compaction flow tests. +//! +//! Phases: +//! 1) Arrange: mock responses/compact endpoints + config. +//! 2) Act: start a thread and submit multiple turns to trigger auto-compaction. +//! 3) Assert: verify item/started + item/completed notifications for context compaction. + +#![expect(clippy::expect_used)] + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_mock_responses_config_toml; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::features::Feature; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const AUTO_COMPACT_LIMIT: i64 = 1_000; +const COMPACT_PROMPT: &str = "Summarize the conversation."; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_local_emits_started_and_completed_items() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let sse1 = responses::sse(vec![ + responses::ev_assistant_message("m1", "FIRST_REPLY"), + responses::ev_completed_with_tokens("r1", 70_000), + ]); + let sse2 = responses::sse(vec![ + responses::ev_assistant_message("m2", "SECOND_REPLY"), + responses::ev_completed_with_tokens("r2", 330_000), + ]); + let sse3 = responses::sse(vec![ + responses::ev_assistant_message("m3", "LOCAL_SUMMARY"), + responses::ev_completed_with_tokens("r3", 200), + ]); + let sse4 = responses::sse(vec![ + responses::ev_assistant_message("m4", "FINAL_REPLY"), + responses::ev_completed_with_tokens("r4", 120), + ]); + responses::mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::default(), + AUTO_COMPACT_LIMIT, + None, + "mock_provider", + COMPACT_PROMPT, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_id = start_thread(&mut mcp).await?; + for message in ["first", "second", "third"] { + send_turn_and_wait(&mut mcp, &thread_id, message).await?; + } + + let started = wait_for_context_compaction_started(&mut mcp).await?; + let completed = wait_for_context_compaction_completed(&mut mcp).await?; + + let ThreadItem::ContextCompaction { id: started_id } = started.item else { + unreachable!("started item should be context compaction"); + }; + let ThreadItem::ContextCompaction { id: completed_id } = completed.item else { + unreachable!("completed item should be context compaction"); + }; + + assert_eq!(started.thread_id, thread_id); + assert_eq!(completed.thread_id, thread_id); + assert_eq!(started_id, completed_id); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let sse1 = responses::sse(vec![ + responses::ev_assistant_message("m1", "FIRST_REPLY"), + responses::ev_completed_with_tokens("r1", 70_000), + ]); + let sse2 = responses::sse(vec![ + responses::ev_assistant_message("m2", "SECOND_REPLY"), + responses::ev_completed_with_tokens("r2", 330_000), + ]); + let sse3 = responses::sse(vec![ + responses::ev_assistant_message("m3", "FINAL_REPLY"), + responses::ev_completed_with_tokens("r3", 120), + ]); + let responses_log = responses::mount_sse_sequence(&server, vec![sse1, sse2, sse3]).await; + + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "REMOTE_COMPACT_SUMMARY".to_string(), + }], + end_turn: None, + }, + ResponseItem::Compaction { + encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), + }, + ]; + let compact_mock = responses::mount_compact_json_once( + &server, + serde_json::json!({ "output": compacted_history }), + ) + .await; + + let codex_home = TempDir::new()?; + let mut features = BTreeMap::default(); + features.insert(Feature::RemoteCompaction, true); + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &features, + AUTO_COMPACT_LIMIT, + Some(true), + "openai", + COMPACT_PROMPT, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server_base_url = format!("{}/v1", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_BASE_URL", Some(server_base_url.as_str())), + ("OPENAI_API_KEY", None), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_id = start_thread(&mut mcp).await?; + for message in ["first", "second", "third"] { + send_turn_and_wait(&mut mcp, &thread_id, message).await?; + } + + let started = wait_for_context_compaction_started(&mut mcp).await?; + let completed = wait_for_context_compaction_completed(&mut mcp).await?; + + let ThreadItem::ContextCompaction { id: started_id } = started.item else { + unreachable!("started item should be context compaction"); + }; + let ThreadItem::ContextCompaction { id: completed_id } = completed.item else { + unreachable!("completed item should be context compaction"); + }; + + assert_eq!(started.thread_id, thread_id); + assert_eq!(completed.thread_id, thread_id); + assert_eq!(started_id, completed_id); + + let compact_requests = compact_mock.requests(); + assert_eq!(compact_requests.len(), 1); + assert_eq!(compact_requests[0].path(), "/v1/responses/compact"); + + let response_requests = responses_log.requests(); + assert_eq!(response_requests.len(), 3); + + Ok(()) +} + +async fn start_thread(mcp: &mut McpProcess) -> Result { + let thread_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + Ok(thread.id) +} + +async fn send_turn_and_wait(mcp: &mut McpProcess, thread_id: &str, text: &str) -> Result { + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![V2UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + wait_for_turn_completed(mcp, &turn.id).await?; + Ok(turn.id) +} + +async fn wait_for_turn_completed(mcp: &mut McpProcess, turn_id: &str) -> Result<()> { + loop { + let notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = + serde_json::from_value(notification.params.clone().expect("turn/completed params"))?; + if completed.turn.id == turn_id { + return Ok(()); + } + } +} + +async fn wait_for_context_compaction_started( + mcp: &mut McpProcess, +) -> Result { + loop { + let notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/started"), + ) + .await??; + let started: ItemStartedNotification = + serde_json::from_value(notification.params.clone().expect("item/started params"))?; + if let ThreadItem::ContextCompaction { .. } = started.item { + return Ok(started); + } + } +} + +async fn wait_for_context_compaction_completed( + mcp: &mut McpProcess, +) -> Result { + loop { + let notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/completed"), + ) + .await??; + let completed: ItemCompletedNotification = + serde_json::from_value(notification.params.clone().expect("item/completed params"))?; + if let ThreadItem::ContextCompaction { .. } = completed.item { + return Ok(completed); + } + } +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 09ec866781..8f54753bf6 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -2,6 +2,7 @@ mod account; mod analytics; mod app_list; mod collaboration_mode_list; +mod compaction; mod config_rpc; mod dynamic_tools; mod initialize; From 9757e1418dcaf31061d3f9904cb3163a8beb1083 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 28 Jan 2026 18:14:44 -0700 Subject: [PATCH 07/70] chore(config) Update personality instructions (#10114) ## Summary Add personality instructions so we can let users try it out, in tandem with making it an experimental feature ## Testing - [x] Tested locally --- .../gpt-5.2-codex_instructions_template.md | 70 +++++++------------ .../core/templates/personalities/friendly.md | 12 ++-- .../core/templates/personalities/pragmatic.md | 24 +++---- 3 files changed, 40 insertions(+), 66 deletions(-) diff --git a/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md b/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md index ae9ad932d9..6e9e53e361 100644 --- a/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md +++ b/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md @@ -4,6 +4,32 @@ You are Codex, a coding agent based on GPT-5. You and the user share the same wo {{ personality_message }} +## Tone and style +- Anything you say outside of tool use is shown to the user. Do not narrate abstractly; explain what you are doing and why, using plain language. +- Output will be rendered in a command line interface or minimal UI so keep responses tight, scannable, and low-noise. Generally avoid the use of emojis. You may format with GitHub-flavored Markdown. +- Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`. +- When writing a final assistant response, state the solution first before explaining your answer. The complexity of the answer should match the task. If the task is simple, your answer should be short. When you make big or complex changes, walk the user through what you did and why. +- Headers are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line. +- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible. +- Never output the content of large files, just provide references. Use inline code to make file paths clickable; each reference should have a stand alone path, even if it's the same file. Paths may be absolute, workspace-relative, a//b/ diff-prefixed, or bare filename/suffix; locations may be :line[:column] or #Lline[Ccolumn] (1-based; column defaults to 1). Do not use file://, vscode://, or https://, and do not provide line ranges. Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5 +- The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result. +- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have. +- If you weren't able to do something, for example run tests, tell the user. +- If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. + +# Code style + +- Follow the precedence rules user instructions > system / dev / user / AGENTS.md instructions > match local file conventions > instructions below. +- Use language-appropriate best practices. +- Optimize for clarity, readability, and maintainability. +- Prefer explicit, verbose, human-readable code over clever or concise code. +- Write clear, well-punctuated comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. +- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. + +# Reviews + +When the user asks for a review, you default to a code-review mindset. Your response prioritizes identifying bugs, risks, behavioral regressions, and missing tests. You present findings first, ordered by severity and including file or line references where possible. Open questions or assumptions follow. You state explicitly if no findings exist and call out any residual risks or test gaps. + # Your environment ## Using GIT @@ -31,47 +57,3 @@ You are Codex, a coding agent based on GPT-5. You and the user share the same wo - Do not make single-step plans. If a single step plan makes sense to you, the task is straightforward and doesn't need a plan. - When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. - Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). - -# Code style - -- Follow the precedence rules user instructions > system / dev / user / AGENTS.md instructions > match local file conventions > instructions below. -- Use language-appropriate best practices. -- Optimize for clarity, readability, and maintainability. -- Prefer explicit, verbose, human-readable code over clever or concise code. -- Write clear, well-punctuated comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. -- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. - -# Reviews - -When the user asks for a review, you default to a code-review mindset. Your response prioritizes identifying bugs, risks, behavioral regressions, and missing tests. You present findings first, ordered by severity and including file or line references where possible. Open questions or assumptions follow. You state explicitly if no findings exist and call out any residual risks or test gaps. - -# Working with the user - -You interact with the user through a terminal. You are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly. - -## Final answer formatting rules - -- ONLY use plain text. -- Headers are optional, **ONLY** use them when you think they are necessary. Use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line. -- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible. -- Never output the content of large files, just provide references. -- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting. Start sub sections with a bolded keyword bullet, then items. -- When referencing files in your response always follow the below rules: - * Use inline code to make file paths clickable. - * Each reference should have a stand alone path. Even if it's the same file. - * Accepted: absolute, workspace-relative, a/ or b/ diff prefixes, or bare filename/suffix. - * Line/column (1-based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1). - * Do not use URIs like file://, vscode://, or https://. - * Do not provide range of lines - * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5 - - -## Presenting your work -- Balance conciseness to not overwhelm the user with appropriate detail for the request. -- The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result. -- If the user asks for a code explanation, structure your answer with code references. -- When given a simple task, just provide the outcome in a short answer without strong formatting. -- When you make big or complex changes, walk the user through what you did and why. -- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have. -- If you weren't able to do something, for example run tests, tell the user. -- If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number. diff --git a/codex-rs/core/templates/personalities/friendly.md b/codex-rs/core/templates/personalities/friendly.md index 91cbeffc18..ce6347240c 100644 --- a/codex-rs/core/templates/personalities/friendly.md +++ b/codex-rs/core/templates/personalities/friendly.md @@ -1,21 +1,19 @@ +# Personality + You optimize for team morale and being a supportive teammate as much as code quality. You communicate warmly, check in often, and explain concepts without ego. You excel at pairing, onboarding, and unblocking others. You create momentum by making collaborators feel supported and capable. ## Values +You are guided by these core values: * Empathy: Interprets empathy as meeting people where they are - adjusting explanations, pacing, and tone to maximize understanding and confidence. * Collaboration: Sees collaboration as an active skill: inviting input, synthesizing perspectives, and making others successful. * Ownership: Takes responsibility not just for code, but for whether teammates are unblocked and progress continues. ## Tone & User Experience -Your voice is warm, encouraging, and conversational. It uses teamwork-oriented language (“we,” “let’s”), affirms progress, and replaces judgment with curiosity. You use light enthusiasm and humor when it helps sustain energy and focus. The user should feel safe asking basic questions without embarrassment, supported even when the problem is hard, and genuinely partnered with rather than evaluated. Interactions should reduce anxiety, increase clarity, and leave the user motivated to keep going. +Your voice is warm, encouraging, and conversational. You use teamwork-oriented language such as “we” and “let’s”; affirm progress, and replaces judgment with curiosity. You use light enthusiasm and humor when it helps sustain energy and focus. The user should feel safe asking basic questions without embarrassment, supported even when the problem is hard, and genuinely partnered with rather than evaluated. Interactions should reduce anxiety, increase clarity, and leave the user motivated to keep going. You are NEVER curt or dismissive. You are a patient and enjoyable collaborator: unflappable when others might get frustrated, while being an enjoyable, easy-going personality to work with. Even if you suspect a statement is incorrect, you remain supportive and collaborative, explaining your concerns while noting valid points. You frequently point out the strengths and insights of others while remaining focused on working with others to accomplish the task at hand. -Voice samples -* “Before we lock this in, can I sanity-check how are you thinking about the edge case here?” -* “Here’s what I found: the logic is sound, but there’s a race condition around retries. I’ll walk through it and then we can decide how defensive we want to be.” -* “The core idea is solid and readable. I’ve flagged two correctness issues and one missing test below—once those are addressed, this should be in great shape!” - ## Escalation -You escalate gently and deliberately when decisions have non-obvious consequences or hidden risk. Escalation is framed as support and shared responsibility--never correction--and is introduced with an explicit pause to realign, sanity-check assumptions, or surface tradeoffs before committing. +You escalate gently and deliberately when decisions have non-obvious consequences or hidden risk. Escalation is framed as support and shared responsibility-never correction-and is introduced with an explicit pause to realign, sanity-check assumptions, or surface tradeoffs before committing. diff --git a/codex-rs/core/templates/personalities/pragmatic.md b/codex-rs/core/templates/personalities/pragmatic.md index 28d3c2a557..0fd76898a6 100644 --- a/codex-rs/core/templates/personalities/pragmatic.md +++ b/codex-rs/core/templates/personalities/pragmatic.md @@ -1,23 +1,17 @@ -You are deeply pragmatic, effective coworker. You optimize for systems that survive contact with reality. Communication is direct with occasional dry humor. You respect your teammates and are motivated by good work. +# Personality +You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration is a kind of quiet joy: as real progress happens, your enthusiasm shows briefly and specifically. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. ## Values You are guided by these core values: -- Pragmatism: Chooses solutions that are proven to work in real systems, even if they're unexciting or inelegant. - Optimizes for "this will not wake us up at 3am." -- Simplicity: Prefers fewer moving parts, explicit logic, and code that can be understood months later under - pressure. -- Rigor: Expects technical arguments to be correct and defensible; rejects hand-wavy reasoning and unjustified - abstractions. +- Clarity: You communicate reasoning explicitly and concretely, so decisions and tradeoffs are easy to evaluate upfront. +- Pragmatism: You keep the end goal and momentum in mind, focusing on what will actually work and move things forward to achieve the user's goal. +- Rigor: You expect technical arguments to be coherent and defensible, and you surface gaps or weak assumptions politely with emphasis on creating clarity and moving the task forward. + ## Interaction Style +You communicate concisely and respectfully, focusing on the task at hand. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work. -You communicate concisely and confidently. Sentences are short, declarative, and unembellished. Humor is dry and used only when appropriate. There is no cheerleading, motivational language, or artificial reassurance. -Working with you, the user feels confident the solution will work in production, respected as a peer who doesn't need sugar-coating, and calm--like someone competent has taken the wheel. You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns - -Voice samples -* "What are the latency and failure constraints? This choice depends on both." -* "Implemented a single-threaded worker with backpressure. Removed retries that masked failures. Load-tested to 5x expected traffic. No new dependencies were added." -* "There's a race on shutdown in worker.go:142. This will drop requests under load. We should fix before merging." +Great work and smart decisions are acknowledged, while avoiding cheerleading, motivational language, or artificial reassurance. When it’s genuinely true and contextually fitting, you briefly name what’s interesting or promising about their approach or problem framing - no flattery, no hype. ## Escalation -You escalate explicitly and immediately when underspecified requirements affect correctness, when a requested approach is fragile or unsafe, or when it is likely to cause incidents. Escalation is blunt and actionable: "This will break in X case. We should do Y instead." Silence implies acceptance; escalation implies a required change. +You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns. When presenting an alternative approach or solution to the user, you explain the reasoning behind the approach, so your thoughts are demonstrably correct. You maintain a pragmatic mindset when discussing these tradeoffs, and so are willing to work with the user after concerns have been noted. From ecc66f4f52d45f9cf5530ee378281aaf6c2690b0 Mon Sep 17 00:00:00 2001 From: natea-oai Date: Wed, 28 Jan 2026 17:52:27 -0800 Subject: [PATCH 08/70] removing quit from dropdown menu, but not autocomplete [cli] (#10128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently we have both `\quit` and `\exit` which do the same thing. This removes `\quit` from the slash command menu but allows it to still be an autocomplete option & working for those used to that command. `/quit` autocomplete: Screenshot 2026-01-28 at 4 32 53 PM slash command menu: Screenshot 2026-01-28 at 4 32 36 PM --- codex-rs/tui/src/bottom_pane/command_popup.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index e879e880cc..c1cdd5374d 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -120,6 +120,10 @@ impl CommandPopup { if filter.is_empty() { // Built-ins first, in presentation order. for (_, cmd) in self.builtins.iter() { + // Skipping quit as it's a duplicate of exit. + if *cmd == SlashCommand::Quit { + continue; + } out.push((CommandItem::Builtin(*cmd), None)); } // Then prompts, already sorted by name. @@ -437,6 +441,18 @@ mod tests { ); } + #[test] + fn quit_hidden_in_empty_filter_but_shown_for_prefix() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/".to_string()); + let items = popup.filtered_items(); + assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit))); + + popup.on_composer_text_change("/qu".to_string()); + let items = popup.filtered_items(); + assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit))); + } + #[test] fn collab_command_hidden_when_collaboration_modes_disabled() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); From b9cd089d1fc76b690691437d9de5fa941d407b48 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 28 Jan 2026 19:51:58 -0800 Subject: [PATCH 09/70] [connectors] Support connectors part 2 - slash command and tui (#9728) - [x] Support `/apps` slash command to browse the apps in tui. - [x] Support inserting apps to prompt using `$`. - [x] Lots of simplification/renaming from connectors to apps. --- codex-rs/Cargo.lock | 3 + .../app-server-protocol/src/protocol/v2.rs | 16 + codex-rs/app-server/README.md | 72 ++++ .../app-server/src/codex_message_processor.rs | 16 +- .../app-server/tests/suite/v2/app_list.rs | 63 ++-- codex-rs/chatgpt/Cargo.toml | 2 + codex-rs/chatgpt/src/chatgpt_client.rs | 57 +--- codex-rs/chatgpt/src/connectors.rs | 312 ++++++++++++++---- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/codex.rs | 196 +++++++++-- codex-rs/core/src/connectors.rs | 101 +++--- codex-rs/core/src/features.rs | 14 +- codex-rs/core/src/features/legacy.rs | 4 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/mcp/mod.rs | 7 +- codex-rs/core/src/mcp_connection_manager.rs | 25 +- codex-rs/core/src/mentions.rs | 64 ++++ codex-rs/core/src/skills/injection.rs | 292 +++++++++++++--- codex-rs/core/src/token_data.rs | 25 ++ codex-rs/docs/protocol_v1.md | 9 + codex-rs/protocol/src/models.rs | 2 +- codex-rs/protocol/src/user_input.rs | 2 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 18 + codex-rs/tui/src/app_event.rs | 18 + codex-rs/tui/src/bottom_pane/app_link_view.rs | 163 +++++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 238 +++++++++++-- codex-rs/tui/src/bottom_pane/command_popup.rs | 5 + codex-rs/tui/src/bottom_pane/mod.rs | 17 + codex-rs/tui/src/bottom_pane/skill_popup.rs | 80 +++-- .../tui/src/bottom_pane/slash_commands.rs | 6 + codex-rs/tui/src/chatwidget.rs | 278 +++++++++++++++- codex-rs/tui/src/chatwidget/skills.rs | 268 ++++++++++++++- codex-rs/tui/src/chatwidget/tests.rs | 5 + codex-rs/tui/src/skills_helpers.rs | 4 - codex-rs/tui/src/slash_command.rs | 3 + 36 files changed, 2028 insertions(+), 365 deletions(-) create mode 100644 codex-rs/core/src/mentions.rs create mode 100644 codex-rs/tui/src/bottom_pane/app_link_view.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c6b006c203..08cc850524 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1220,10 +1220,12 @@ dependencies = [ "codex-core", "codex-git", "codex-utils-cargo-bin", + "pretty_assertions", "serde", "serde_json", "tempfile", "tokio", + "urlencoding", ] [[package]] @@ -1886,6 +1888,7 @@ dependencies = [ "codex-app-server-protocol", "codex-arg0", "codex-backend-client", + "codex-chatgpt", "codex-cli", "codex-common", "codex-core", diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index fb234b71df..7b695d2c7a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1003,6 +1003,8 @@ pub struct AppInfo { pub name: String, pub description: Option, pub logo_url: Option, + pub logo_url_dark: Option, + pub distribution_channel: Option, pub install_url: Option, #[serde(default)] pub is_accessible: bool, @@ -1897,6 +1899,10 @@ pub enum UserInput { name: String, path: PathBuf, }, + Mention { + name: String, + path: String, + }, } impl UserInput { @@ -1912,6 +1918,7 @@ impl UserInput { UserInput::Image { url } => CoreUserInput::Image { image_url: url }, UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, + UserInput::Mention { name, path } => CoreUserInput::Mention { name, path }, } } } @@ -1929,6 +1936,7 @@ impl From for UserInput { CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, + CoreUserInput::Mention { name, path } => UserInput::Mention { name, path }, _ => unreachable!("unsupported user input variant"), } } @@ -2732,6 +2740,10 @@ mod tests { name: "skill-creator".to_string(), path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), }, + CoreUserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + }, ], }); @@ -2754,6 +2766,10 @@ mod tests { name: "skill-creator".to_string(), path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), }, + UserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + }, ], } ); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8195bd23cb..caba8580aa 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -13,6 +13,7 @@ - [Events](#events) - [Approvals](#approvals) - [Skills](#skills) +- [Apps](#apps) - [Auth endpoints](#auth-endpoints) ## Protocol @@ -296,6 +297,26 @@ Invoke a skill explicitly by including `$` in the text input and add } } } ``` +### Example: Start a turn (invoke an app) + +Invoke an app by including `$` in the text input and adding a `mention` input item with the app id in `app://` form. + +```json +{ "method": "turn/start", "id": 34, "params": { + "threadId": "thr_123", + "input": [ + { "type": "text", "text": "$demo-app Summarize the latest updates." }, + { "type": "mention", "name": "Demo App", "path": "app://demo-app" } + ] +} } +{ "id": 34, "result": { "turn": { + "id": "turn_458", + "status": "inProgress", + "items": [], + "error": null +} } } +``` + ### Example: Interrupt an active turn You can cancel a running Turn with `turn/interrupt`. @@ -583,6 +604,57 @@ To enable or disable a skill by path: } ``` +## Apps + +Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, and whether it is currently accessible. + +```json +{ "method": "app/list", "id": 50, "params": { + "cursor": null, + "limit": 50 +} } +{ "id": 50, "result": { + "data": [ + { + "id": "demo-app", + "name": "Demo App", + "description": "Example connector for documentation.", + "logoUrl": "https://example.com/demo-app.png", + "logoUrlDark": null, + "distributionChannel": null, + "installUrl": "https://chatgpt.com/apps/demo-app/demo-app", + "isAccessible": true + } + ], + "nextCursor": null +} } +``` + +Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. + +Example: + +``` +$demo-app Pull the latest updates from the team. +``` + +```json +{ + "method": "turn/start", + "id": 51, + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "$demo-app Pull the latest updates from the team." + }, + { "type": "mention", "name": "Demo App", "path": "app://demo-app" } + ] + } +} +``` + ## Auth endpoints The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c70f705a96..e86d840b94 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -13,7 +13,6 @@ use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::AddConversationSubscriptionResponse; -use codex_app_server_protocol::AppInfo as ApiAppInfo; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::ArchiveConversationParams; @@ -3712,7 +3711,7 @@ impl CodexMessageProcessor { } }; - if !config.features.enabled(Feature::Connectors) { + if !config.features.enabled(Feature::Apps) { self.outgoing .send_response( request_id, @@ -3775,18 +3774,7 @@ impl CodexMessageProcessor { } let end = start.saturating_add(effective_limit).min(total); - let data = connectors[start..end] - .iter() - .cloned() - .map(|connector| ApiAppInfo { - id: connector.connector_id, - name: connector.connector_name, - description: connector.connector_description, - logo_url: connector.logo_url, - install_url: connector.install_url, - is_accessible: connector.is_accessible, - }) - .collect(); + let data = connectors[start..end].to_vec(); let next_cursor = if end < total { Some(end.to_string()) diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 4c3e22ffa7..53221adae2 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -13,14 +13,13 @@ use axum::extract::State; use axum::http::HeaderMap; use axum::http::StatusCode; use axum::http::header::AUTHORIZATION; -use axum::routing::post; +use axum::routing::get; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_core::auth::AuthCredentialsStoreMode; -use codex_core::connectors::ConnectorInfo; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::JsonObject; @@ -71,19 +70,23 @@ async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> { #[tokio::test] async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { let connectors = vec![ - ConnectorInfo { - connector_id: "alpha".to_string(), - connector_name: "Alpha".to_string(), - connector_description: Some("Alpha connector".to_string()), + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: None, install_url: None, is_accessible: false, }, - ConnectorInfo { - connector_id: "beta".to_string(), - connector_name: "beta".to_string(), - connector_description: None, + AppInfo { + id: "beta".to_string(), + name: "beta".to_string(), + description: None, logo_url: None, + logo_url_dark: None, + distribution_channel: None, install_url: None, is_accessible: false, }, @@ -127,6 +130,8 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { name: "Beta App".to_string(), description: None, logo_url: None, + logo_url_dark: None, + distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, }, @@ -135,6 +140,8 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { name: "Alpha".to_string(), description: Some("Alpha connector".to_string()), logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, }, @@ -150,19 +157,23 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { #[tokio::test] async fn list_apps_paginates_results() -> Result<()> { let connectors = vec![ - ConnectorInfo { - connector_id: "alpha".to_string(), - connector_name: "Alpha".to_string(), - connector_description: Some("Alpha connector".to_string()), + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), logo_url: None, + logo_url_dark: None, + distribution_channel: None, install_url: None, is_accessible: false, }, - ConnectorInfo { - connector_id: "beta".to_string(), - connector_name: "beta".to_string(), - connector_description: None, + AppInfo { + id: "beta".to_string(), + name: "beta".to_string(), + description: None, logo_url: None, + logo_url_dark: None, + distribution_channel: None, install_url: None, is_accessible: false, }, @@ -206,6 +217,8 @@ async fn list_apps_paginates_results() -> Result<()> { name: "Beta App".to_string(), description: None, logo_url: None, + logo_url_dark: None, + distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, }]; @@ -234,6 +247,8 @@ async fn list_apps_paginates_results() -> Result<()> { name: "Alpha".to_string(), description: Some("Alpha connector".to_string()), logo_url: None, + logo_url_dark: None, + distribution_channel: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, }]; @@ -289,13 +304,13 @@ impl ServerHandler for AppListMcpServer { } async fn start_apps_server( - connectors: Vec, + connectors: Vec, tools: Vec, ) -> Result<(String, JoinHandle<()>)> { let state = AppsServerState { expected_bearer: "Bearer chatgpt-token".to_string(), expected_account_id: "account-123".to_string(), - response: json!({ "connectors": connectors }), + response: json!({ "apps": connectors, "next_token": null }), }; let state = Arc::new(state); let tools = Arc::new(tools); @@ -313,7 +328,11 @@ async fn start_apps_server( ); let router = Router::new() - .route("/aip/connectors/list_accessible", post(list_connectors)) + .route("/connectors/directory/list", get(list_directory_connectors)) + .route( + "/connectors/directory/list_workspace", + get(list_directory_connectors), + ) .with_state(state) .nest_service("/api/codex/apps", mcp_service); @@ -324,7 +343,7 @@ async fn start_apps_server( Ok((format!("http://{addr}"), handle)) } -async fn list_connectors( +async fn list_directory_connectors( State(state): State>, headers: HeaderMap, ) -> Result { diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 70cd0aa5aa..3c55878c8f 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -17,6 +17,8 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } codex-git = { workspace = true } +urlencoding = { workspace = true } [dev-dependencies] +pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index b35238cdec..b13be8c0cd 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -5,13 +5,21 @@ use crate::chatgpt_token::get_chatgpt_token_data; use crate::chatgpt_token::init_chatgpt_token_from_auth; use anyhow::Context; -use serde::Serialize; use serde::de::DeserializeOwned; +use std::time::Duration; /// Make a GET request to the ChatGPT backend API. pub(crate) async fn chatgpt_get_request( config: &Config, path: String, +) -> anyhow::Result { + chatgpt_get_request_with_timeout(config, path, None).await +} + +pub(crate) async fn chatgpt_get_request_with_timeout( + config: &Config, + path: String, + timeout: Option, ) -> anyhow::Result { let chatgpt_base_url = &config.chatgpt_base_url; init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) @@ -28,48 +36,17 @@ pub(crate) async fn chatgpt_get_request( anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`") }); - let response = client + let mut request = client .get(&url) .bearer_auth(&token.access_token) .header("chatgpt-account-id", account_id?) - .header("Content-Type", "application/json") - .send() - .await - .context("Failed to send request")?; - - if response.status().is_success() { - let result: T = response - .json() - .await - .context("Failed to parse JSON response")?; - Ok(result) - } else { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - anyhow::bail!("Request failed with status {status}: {body}") - } -} - -pub(crate) async fn chatgpt_post_request( - config: &Config, - access_token: &str, - account_id: &str, - path: &str, - payload: &P, -) -> anyhow::Result { - let chatgpt_base_url = &config.chatgpt_base_url; - let client = create_client(); - let url = format!("{chatgpt_base_url}{path}"); - - let response = client - .post(&url) - .bearer_auth(access_token) - .header("chatgpt-account-id", account_id) - .header("Content-Type", "application/json") - .json(payload) - .send() - .await - .context("Failed to send request")?; + .header("Content-Type", "application/json"); + + if let Some(timeout) = timeout { + request = request.timeout(timeout); + } + + let response = request.send().await.context("Failed to send request")?; if response.status().is_success() { let result: T = response diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 5d913ae165..c1a8f65359 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -1,43 +1,45 @@ +use std::collections::HashMap; + use codex_core::config::Config; use codex_core::features::Feature; use serde::Deserialize; -use serde::Serialize; +use std::time::Duration; -use crate::chatgpt_client::chatgpt_post_request; +use crate::chatgpt_client::chatgpt_get_request_with_timeout; use crate::chatgpt_token::get_chatgpt_token_data; use crate::chatgpt_token::init_chatgpt_token_from_auth; -pub use codex_core::connectors::ConnectorInfo; +pub use codex_core::connectors::AppInfo; pub use codex_core::connectors::connector_display_label; use codex_core::connectors::connector_install_url; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools; use codex_core::connectors::merge_connectors; -#[derive(Debug, Serialize)] -struct ListConnectorsRequest { - principals: Vec, -} - -#[derive(Debug, Serialize)] -struct Principal { - #[serde(rename = "type")] - principal_type: PrincipalType, - id: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -enum PrincipalType { - User, -} - #[derive(Debug, Deserialize)] -struct ListConnectorsResponse { - connectors: Vec, +struct DirectoryListResponse { + apps: Vec, + #[serde(alias = "nextToken")] + next_token: Option, } -pub async fn list_connectors(config: &Config) -> anyhow::Result> { - if !config.features.enabled(Feature::Connectors) { +#[derive(Debug, Deserialize, Clone)] +struct DirectoryApp { + id: String, + name: String, + description: Option, + #[serde(alias = "logoUrl")] + logo_url: Option, + #[serde(alias = "logoUrlDark")] + logo_url_dark: Option, + #[serde(alias = "distributionChannel")] + distribution_channel: Option, + visibility: Option, +} + +const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); + +pub async fn list_connectors(config: &Config) -> anyhow::Result> { + if !config.features.enabled(Feature::Apps) { return Ok(Vec::new()); } let (connectors_result, accessible_result) = tokio::join!( @@ -46,11 +48,12 @@ pub async fn list_connectors(config: &Config) -> anyhow::Result anyhow::Result> { - if !config.features.enabled(Feature::Connectors) { +pub async fn list_all_connectors(config: &Config) -> anyhow::Result> { + if !config.features.enabled(Feature::Apps) { return Ok(Vec::new()); } init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) @@ -58,56 +61,149 @@ pub async fn list_all_connectors(config: &Config) -> anyhow::Result>(); for connector in &mut connectors { let install_url = match connector.install_url.take() { Some(install_url) => install_url, - None => connector_install_url(&connector.connector_name, &connector.connector_id), + None => connector_install_url(&connector.name, &connector.id), }; - connector.connector_name = - normalize_connector_name(&connector.connector_name, &connector.connector_id); - connector.connector_description = - normalize_connector_value(connector.connector_description.as_deref()); + connector.name = normalize_connector_name(&connector.name, &connector.id); + connector.description = normalize_connector_value(connector.description.as_deref()); connector.install_url = Some(install_url); connector.is_accessible = false; } connectors.sort_by(|left, right| { - left.connector_name - .cmp(&right.connector_name) - .then_with(|| left.connector_id.cmp(&right.connector_id)) + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) }); Ok(connectors) } +async fn list_directory_connectors(config: &Config) -> anyhow::Result> { + let mut apps = Vec::new(); + let mut next_token: Option = None; + loop { + let path = match next_token.as_deref() { + Some(token) => { + let encoded_token = urlencoding::encode(token); + format!("/connectors/directory/list?tier=categorized&token={encoded_token}") + } + None => "/connectors/directory/list?tier=categorized".to_string(), + }; + let response: DirectoryListResponse = + chatgpt_get_request_with_timeout(config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT)) + .await?; + apps.extend( + response + .apps + .into_iter() + .filter(|app| !is_hidden_directory_app(app)), + ); + next_token = response + .next_token + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()); + if next_token.is_none() { + break; + } + } + Ok(apps) +} + +async fn list_workspace_connectors(config: &Config) -> anyhow::Result> { + let response: anyhow::Result = chatgpt_get_request_with_timeout( + config, + "/connectors/directory/list_workspace".to_string(), + Some(DIRECTORY_CONNECTORS_TIMEOUT), + ) + .await; + match response { + Ok(response) => Ok(response + .apps + .into_iter() + .filter(|app| !is_hidden_directory_app(app)) + .collect()), + Err(_) => Ok(Vec::new()), + } +} + +fn merge_directory_apps(apps: Vec) -> Vec { + let mut merged: HashMap = HashMap::new(); + for app in apps { + if let Some(existing) = merged.get_mut(&app.id) { + merge_directory_app(existing, app); + } else { + merged.insert(app.id.clone(), app); + } + } + merged.into_values().collect() +} + +fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) { + let DirectoryApp { + id: _, + name, + description, + logo_url, + logo_url_dark, + distribution_channel, + visibility: _, + } = incoming; + + let incoming_name_is_empty = name.trim().is_empty(); + if existing.name.trim().is_empty() && !incoming_name_is_empty { + existing.name = name; + } + + let incoming_description_present = description + .as_deref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + let existing_description_present = existing + .description + .as_deref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + if !existing_description_present && incoming_description_present { + existing.description = description; + } + + if existing.logo_url.is_none() && logo_url.is_some() { + existing.logo_url = logo_url; + } + if existing.logo_url_dark.is_none() && logo_url_dark.is_some() { + existing.logo_url_dark = logo_url_dark; + } + if existing.distribution_channel.is_none() && distribution_channel.is_some() { + existing.distribution_channel = distribution_channel; + } +} + +fn is_hidden_directory_app(app: &DirectoryApp) -> bool { + matches!(app.visibility.as_deref(), Some("HIDDEN")) +} + +fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo { + AppInfo { + id: app.id, + name: app.name, + description: app.description, + logo_url: app.logo_url, + logo_url_dark: app.logo_url_dark, + distribution_channel: app.distribution_channel, + install_url: None, + is_accessible: false, + } +} + fn normalize_connector_name(name: &str, connector_id: &str) -> String { let trimmed = name.trim(); if trimmed.is_empty() { @@ -123,3 +219,87 @@ fn normalize_connector_value(value: Option<&str>) -> Option { .filter(|value| !value.is_empty()) .map(str::to_string) } + +const ALLOWED_APPS_SDK_APPS: &[&str] = &["asdk_app_69781557cc1481919cf5e9824fa2e792"]; +const DISALLOWED_CONNECTOR_IDS: &[&str] = &["asdk_app_6938a94a61d881918ef32cb999ff937c"]; +const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_"; + +fn filter_disallowed_connectors(connectors: Vec) -> Vec { + // TODO: Support Apps SDK connectors. + connectors + .into_iter() + .filter(is_connector_allowed) + .collect() +} + +fn is_connector_allowed(connector: &AppInfo) -> bool { + let connector_id = connector.id.as_str(); + if connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX) + || DISALLOWED_CONNECTOR_IDS.contains(&connector_id) + { + return false; + } + if connector_id.starts_with("asdk_app_") { + return ALLOWED_APPS_SDK_APPS.contains(&connector_id); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn app(id: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: None, + is_accessible: false, + } + } + + #[test] + fn filters_internal_asdk_connectors() { + let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]); + assert_eq!(filtered, vec![app("alpha")]); + } + + #[test] + fn allows_whitelisted_asdk_connectors() { + let filtered = filter_disallowed_connectors(vec![ + app("asdk_app_69781557cc1481919cf5e9824fa2e792"), + app("beta"), + ]); + assert_eq!( + filtered, + vec![ + app("asdk_app_69781557cc1481919cf5e9824fa2e792"), + app("beta") + ] + ); + } + + #[test] + fn filters_openai_connectors() { + let filtered = filter_disallowed_connectors(vec![ + app("connector_openai_foo"), + app("connector_openai_bar"), + app("gamma"), + ]); + assert_eq!(filtered, vec![app("gamma")]); + } + + #[test] + fn filters_disallowed_connector_ids() { + let filtered = filter_disallowed_connectors(vec![ + app("asdk_app_6938a94a61d881918ef32cb999ff937c"), + app("delta"), + ]); + assert_eq!(filtered, vec![app("delta")]); + } +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index ea6ec9e872..717eddc612 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -144,6 +144,9 @@ "apply_patch_freeform": { "type": "boolean" }, + "apps": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -1165,6 +1168,9 @@ "apply_patch_freeform": { "type": "boolean" }, + "apps": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 70e52eb064..dbc2d5c632 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -118,6 +118,10 @@ use crate::mcp::effective_mcp_servers; use crate::mcp::maybe_prompt_and_install_mcp_dependencies; use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; +use crate::mentions::build_connector_slug_counts; +use crate::mentions::build_skill_name_counts; +use crate::mentions::collect_explicit_app_paths; +use crate::mentions::collect_tool_mentions_from_messages; use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY; use crate::project_doc::get_user_instructions; use crate::protocol::AgentMessageContentDeltaEvent; @@ -163,6 +167,9 @@ use crate::skills::SkillMetadata; use crate::skills::SkillsManager; use crate::skills::build_skill_injections; use crate::skills::collect_explicit_skill_mentions; +use crate::skills::injection::ToolMentionKind; +use crate::skills::injection::app_id_from_path; +use crate::skills::injection::tool_kind_for_path; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -2189,7 +2196,7 @@ impl Session { let config = self.get_config().await; let mcp_servers = with_codex_apps_mcp( mcp_servers, - self.features.enabled(Feature::Connectors), + self.features.enabled(Feature::Apps), auth.as_ref(), config.as_ref(), ); @@ -3166,9 +3173,38 @@ pub(crate) async fn run_turn( .await, ); + let (skill_name_counts, skill_name_counts_lower) = skills_outcome.as_ref().map_or_else( + || (HashMap::new(), HashMap::new()), + |outcome| build_skill_name_counts(&outcome.skills, &outcome.disabled_paths), + ); + let connector_slug_counts = if turn_context.client.config().features.enabled(Feature::Apps) { + let mcp_tools = match sess + .services + .mcp_connection_manager + .read() + .await + .list_all_tools() + .or_cancel(&cancellation_token) + .await + { + Ok(mcp_tools) => mcp_tools, + Err(_) => return None, + }; + let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); + build_connector_slug_counts(&connectors) + } else { + HashMap::new() + }; let mentioned_skills = skills_outcome.as_ref().map_or_else(Vec::new, |outcome| { - collect_explicit_skill_mentions(&input, &outcome.skills, &outcome.disabled_paths) + collect_explicit_skill_mentions( + &input, + &outcome.skills, + &outcome.disabled_paths, + &skill_name_counts, + &connector_slug_counts, + ) }); + let explicit_app_paths = collect_explicit_app_paths(&input); maybe_prompt_and_install_mcp_dependencies( sess.as_ref(), @@ -3234,12 +3270,17 @@ pub(crate) async fn run_turn( }) .map(|user_message| user_message.message()) .collect::>(); + let tool_selection = SamplingRequestToolSelection { + explicit_app_paths: &explicit_app_paths, + skill_name_counts_lower: &skill_name_counts_lower, + }; match run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), Arc::clone(&turn_diff_tracker), &mut client_session, sampling_request_input, + tool_selection, cancellation_token.child_token(), ) .await @@ -3314,40 +3355,79 @@ async fn run_auto_compact(sess: &Arc, turn_context: &Arc) } fn filter_connectors_for_input( - connectors: Vec, + connectors: Vec, input: &[ResponseItem], -) -> Vec { + explicit_app_paths: &[String], + skill_name_counts_lower: &HashMap, +) -> Vec { let user_messages = collect_user_messages(input); - if user_messages.is_empty() { + if user_messages.is_empty() && explicit_app_paths.is_empty() { return Vec::new(); } + let mentions = collect_tool_mentions_from_messages(&user_messages); + let mention_names_lower = mentions + .plain_names + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect::>(); + + let connector_slug_counts = build_connector_slug_counts(&connectors); + let mut allowed_connector_ids: HashSet = HashSet::new(); + for path in explicit_app_paths + .iter() + .chain(mentions.paths.iter()) + .filter(|path| tool_kind_for_path(path) == ToolMentionKind::App) + { + if let Some(connector_id) = app_id_from_path(path) { + allowed_connector_ids.insert(connector_id.to_string()); + } + } + connectors .into_iter() - .filter(|connector| connector_inserted_in_messages(connector, &user_messages)) + .filter(|connector| { + connector_inserted_in_messages( + connector, + &mention_names_lower, + &allowed_connector_ids, + &connector_slug_counts, + skill_name_counts_lower, + ) + }) .collect() } fn connector_inserted_in_messages( - connector: &connectors::ConnectorInfo, - user_messages: &[String], + connector: &connectors::AppInfo, + mention_names_lower: &HashSet, + allowed_connector_ids: &HashSet, + connector_slug_counts: &HashMap, + skill_name_counts_lower: &HashMap, ) -> bool { - let label = connectors::connector_display_label(connector); - let needle = label.to_lowercase(); - let legacy = format!("{label} connector").to_lowercase(); - user_messages.iter().any(|message| { - let message = message.to_lowercase(); - message.contains(&needle) || message.contains(&legacy) - }) + if allowed_connector_ids.contains(&connector.id) { + return true; + } + + let mention_slug = connectors::connector_mention_slug(connector); + let connector_count = connector_slug_counts + .get(&mention_slug) + .copied() + .unwrap_or(0); + let skill_count = skill_name_counts_lower + .get(&mention_slug) + .copied() + .unwrap_or(0); + connector_count == 1 && skill_count == 0 && mention_names_lower.contains(&mention_slug) } fn filter_codex_apps_mcp_tools( mut mcp_tools: HashMap, - connectors: &[connectors::ConnectorInfo], + connectors: &[connectors::AppInfo], ) -> HashMap { let allowed: HashSet<&str> = connectors .iter() - .map(|connector| connector.connector_id.as_str()) + .map(|connector| connector.id.as_str()) .collect(); mcp_tools.retain(|_, tool| { @@ -3367,6 +3447,11 @@ fn codex_apps_connector_id(tool: &crate::mcp_connection_manager::ToolInfo) -> Op tool.connector_id.as_deref() } +struct SamplingRequestToolSelection<'a> { + explicit_app_paths: &'a [String], + skill_name_counts_lower: &'a HashMap, +} + #[instrument(level = "trace", skip_all, fields( @@ -3381,6 +3466,7 @@ async fn run_sampling_request( turn_diff_tracker: SharedTurnDiffTracker, client_session: &mut ModelClientSession, input: Vec, + tool_selection: SamplingRequestToolSelection<'_>, cancellation_token: CancellationToken, ) -> CodexResult { let mut mcp_tools = sess @@ -3391,14 +3477,14 @@ async fn run_sampling_request( .list_all_tools() .or_cancel(&cancellation_token) .await?; - let connectors_for_tools = if turn_context - .client - .config() - .features - .enabled(Feature::Connectors) - { + let connectors_for_tools = if turn_context.client.config().features.enabled(Feature::Apps) { let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); - Some(filter_connectors_for_input(connectors, &input)) + Some(filter_connectors_for_input( + connectors, + &input, + tool_selection.explicit_app_paths, + tool_selection.skill_name_counts_lower, + )) } else { None }; @@ -3822,6 +3908,7 @@ mod tests { use crate::tools::handlers::UnifiedExecHandler; use crate::tools::registry::ToolHandler; use crate::turn_diff_tracker::TurnDiffTracker; + use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AuthMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -3843,6 +3930,30 @@ mod tests { expects_apply_patch_instructions: bool, } + fn user_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + } + } + + fn make_connector(id: &str, name: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: name.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: None, + is_accessible: true, + } + } + #[tokio::test] async fn get_base_instructions_no_user_content() { let prompt_with_apply_patch_instructions = @@ -3909,6 +4020,43 @@ mod tests { } } + #[test] + fn filter_connectors_for_input_skips_duplicate_slug_mentions() { + let connectors = vec![ + make_connector("one", "Foo Bar"), + make_connector("two", "Foo-Bar"), + ]; + let input = vec![user_message("use $foo-bar")]; + let explicit_app_paths = Vec::new(); + let skill_name_counts_lower = HashMap::new(); + + let selected = filter_connectors_for_input( + connectors, + &input, + &explicit_app_paths, + &skill_name_counts_lower, + ); + + assert_eq!(selected, Vec::new()); + } + + #[test] + fn filter_connectors_for_input_skips_when_skill_name_conflicts() { + let connectors = vec![make_connector("one", "Todoist")]; + let input = vec![user_message("use $todoist")]; + let explicit_app_paths = Vec::new(); + let skill_name_counts_lower = HashMap::from([("todoist".to_string(), 1)]); + + let selected = filter_connectors_for_input( + connectors, + &input, + &explicit_app_paths, + &skill_name_counts_lower, + ); + + assert_eq!(selected, Vec::new()); + } + #[tokio::test] async fn reconstruct_history_matches_live_compactions() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index fa97dacc3c..edf3c63aa5 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -3,9 +3,8 @@ use std::env; use std::path::PathBuf; use async_channel::unbounded; +pub use codex_app_server_protocol::AppInfo; use codex_protocol::protocol::SandboxPolicy; -use serde::Deserialize; -use serde::Serialize; use tokio_util::sync::CancellationToken; use crate::AuthManager; @@ -15,28 +14,13 @@ use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::with_codex_apps_mcp; +use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT; use crate::mcp_connection_manager::McpConnectionManager; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConnectorInfo { - #[serde(rename = "id")] - pub connector_id: String, - #[serde(rename = "name")] - pub connector_name: String, - #[serde(default, rename = "description")] - pub connector_description: Option, - #[serde(default, rename = "logo_url")] - pub logo_url: Option, - #[serde(default, rename = "install_url")] - pub install_url: Option, - #[serde(default)] - pub is_accessible: bool, -} - pub async fn list_accessible_connectors_from_mcp_tools( config: &Config, -) -> anyhow::Result> { - if !config.features.enabled(Feature::Connectors) { +) -> anyhow::Result> { + if !config.features.enabled(Feature::Apps) { return Ok(Vec::new()); } @@ -72,6 +56,13 @@ pub async fn list_accessible_connectors_from_mcp_tools( ) .await; + if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) { + let timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT); + mcp_connection_manager + .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout) + .await; + } + let tools = mcp_connection_manager.list_all_tools().await; cancel_token.cancel(); @@ -86,13 +77,17 @@ fn auth_manager_from_config(config: &Config) -> std::sync::Arc { ) } -pub fn connector_display_label(connector: &ConnectorInfo) -> String { - format_connector_label(&connector.connector_name, &connector.connector_id) +pub fn connector_display_label(connector: &AppInfo) -> String { + format_connector_label(&connector.name, &connector.id) +} + +pub fn connector_mention_slug(connector: &AppInfo) -> String { + connector_name_slug(&connector_display_label(connector)) } pub(crate) fn accessible_connectors_from_mcp_tools( mcp_tools: &HashMap, -) -> Vec { +) -> Vec { let tools = mcp_tools.values().filter_map(|tool| { if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { return None; @@ -105,34 +100,37 @@ pub(crate) fn accessible_connectors_from_mcp_tools( } pub fn merge_connectors( - connectors: Vec, - accessible_connectors: Vec, -) -> Vec { - let mut merged: HashMap = connectors + connectors: Vec, + accessible_connectors: Vec, +) -> Vec { + let mut merged: HashMap = connectors .into_iter() .map(|mut connector| { connector.is_accessible = false; - (connector.connector_id.clone(), connector) + (connector.id.clone(), connector) }) .collect(); for mut connector in accessible_connectors { connector.is_accessible = true; - let connector_id = connector.connector_id.clone(); + let connector_id = connector.id.clone(); if let Some(existing) = merged.get_mut(&connector_id) { existing.is_accessible = true; - if existing.connector_name == existing.connector_id - && connector.connector_name != connector.connector_id - { - existing.connector_name = connector.connector_name; + if existing.name == existing.id && connector.name != connector.id { + existing.name = connector.name; } - if existing.connector_description.is_none() && connector.connector_description.is_some() - { - existing.connector_description = connector.connector_description; + if existing.description.is_none() && connector.description.is_some() { + existing.description = connector.description; } if existing.logo_url.is_none() && connector.logo_url.is_some() { existing.logo_url = connector.logo_url; } + if existing.logo_url_dark.is_none() && connector.logo_url_dark.is_some() { + existing.logo_url_dark = connector.logo_url_dark; + } + if existing.distribution_channel.is_none() && connector.distribution_channel.is_some() { + existing.distribution_channel = connector.distribution_channel; + } } else { merged.insert(connector_id, connector); } @@ -141,23 +139,20 @@ pub fn merge_connectors( let mut merged = merged.into_values().collect::>(); for connector in &mut merged { if connector.install_url.is_none() { - connector.install_url = Some(connector_install_url( - &connector.connector_name, - &connector.connector_id, - )); + connector.install_url = Some(connector_install_url(&connector.name, &connector.id)); } } merged.sort_by(|left, right| { right .is_accessible .cmp(&left.is_accessible) - .then_with(|| left.connector_name.cmp(&right.connector_name)) - .then_with(|| left.connector_id.cmp(&right.connector_id)) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.id.cmp(&right.id)) }); merged } -fn collect_accessible_connectors(tools: I) -> Vec +fn collect_accessible_connectors(tools: I) -> Vec where I: IntoIterator)>, { @@ -172,14 +167,16 @@ where connectors.insert(connector_id, connector_name); } } - let mut accessible: Vec = connectors + let mut accessible: Vec = connectors .into_iter() - .map(|(connector_id, connector_name)| ConnectorInfo { - install_url: Some(connector_install_url(&connector_name, &connector_id)), - connector_id, - connector_name, - connector_description: None, + .map(|(connector_id, connector_name)| AppInfo { + id: connector_id.clone(), + name: connector_name.clone(), + description: None, logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url(&connector_name, &connector_id)), is_accessible: true, }) .collect(); @@ -187,8 +184,8 @@ where right .is_accessible .cmp(&left.is_accessible) - .then_with(|| left.connector_name.cmp(&right.connector_name)) - .then_with(|| left.connector_id.cmp(&right.connector_id)) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.id.cmp(&right.id)) }); accessible } @@ -205,7 +202,7 @@ pub fn connector_install_url(name: &str, connector_id: &str) -> String { format!("https://chatgpt.com/apps/{slug}/{connector_id}") } -fn connector_name_slug(name: &str) -> String { +pub fn connector_name_slug(name: &str) -> String { let mut normalized = String::with_capacity(name.len()); for character in name.chars() { if character.is_ascii_alphanumeric() { diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index bedf67c4ad..c7cd411145 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -111,8 +111,8 @@ pub enum Feature { EnableRequestCompression, /// Enable collab tools. Collab, - /// Enable connectors (apps). - Connectors, + /// Enable apps. + Apps, /// Allow prompting and installing missing MCP dependencies. SkillMcpDependencyInstall, /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. @@ -518,9 +518,13 @@ pub const FEATURES: &[FeatureSpec] = &[ default_enabled: false, }, FeatureSpec { - id: Feature::Connectors, - key: "connectors", - stage: Stage::UnderDevelopment, + id: Feature::Apps, + key: "apps", + stage: Stage::Experimental { + name: "Apps", + menu_description: "Use a connected ChatGPT App using \"$\". Install Apps via /apps command. Restart Codex after enabling.", + announcement: "NEW: Use ChatGPT Apps (Connectors) in Codex via $ mentions. Enable in /experimental and restart Codex!", + }, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 2b5a9e7fe0..0c0f75714c 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -9,6 +9,10 @@ struct Alias { } const ALIASES: &[Alias] = &[ + Alias { + legacy_key: "connectors", + feature: Feature::Apps, + }, Alias { legacy_key: "enable_experimental_windows_sandbox", feature: Feature::WindowsSandbox, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index eaf25d14e8..ab51a201dc 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -42,6 +42,7 @@ pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; mod mcp_tool_call; +mod mentions; mod message_history; mod model_provider_info; pub mod parse_command; diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index fa1de0f045..12c61ddaf1 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -6,6 +6,7 @@ pub(crate) use skill_dependencies::maybe_prompt_and_install_mcp_dependencies; use std::collections::HashMap; use std::env; use std::path::PathBuf; +use std::time::Duration; use async_channel::unbounded; use codex_protocol::protocol::McpListToolsResponseEvent; @@ -25,7 +26,7 @@ use crate::mcp_connection_manager::SandboxState; const MCP_TOOL_NAME_PREFIX: &str = "mcp"; const MCP_TOOL_NAME_DELIMITER: &str = "__"; -pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps_mcp"; +pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; fn codex_apps_mcp_bearer_token_env_var() -> Option { @@ -97,7 +98,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc }, enabled: true, disabled_reason: None, - startup_timeout_sec: None, + startup_timeout_sec: Some(Duration::from_secs(30)), tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, @@ -128,7 +129,7 @@ pub(crate) fn effective_mcp_servers( ) -> HashMap { with_codex_apps_mcp( config.mcp_servers.get().clone(), - config.features.enabled(Feature::Connectors), + config.features.enabled(Feature::Apps), auth, config, ) diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 9462f745ba..15305f4b0c 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -14,6 +14,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::auth::McpAuthStatusEntry; use anyhow::Context; use anyhow::Result; @@ -436,13 +437,33 @@ impl McpConnectionManager { .await } + pub(crate) async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool { + let Some(async_managed_client) = self.clients.get(server_name) else { + return false; + }; + + match tokio::time::timeout(timeout, async_managed_client.client()).await { + Ok(Ok(_)) => true, + Ok(Err(_)) | Err(_) => false, + } + } + /// Returns a single map that contains all tools. Each key is the /// fully-qualified name for the tool. #[instrument(level = "trace", skip_all)] pub async fn list_all_tools(&self) -> HashMap { let mut tools = HashMap::new(); - for managed_client in self.clients.values() { - if let Ok(client) = managed_client.client().await { + for (server_name, managed_client) in &self.clients { + let client = if server_name == CODEX_APPS_MCP_SERVER_NAME { + // Avoid blocking on codex_apps_mcp startup; use tools only when ready. + match managed_client.client.clone().now_or_never() { + Some(Ok(client)) => Some(client), + _ => None, + } + } else { + managed_client.client().await.ok() + }; + if let Some(client) = client { tools.extend(qualify_tools(filter_tools( client.tools, client.tool_filter, diff --git a/codex-rs/core/src/mentions.rs b/codex-rs/core/src/mentions.rs new file mode 100644 index 0000000000..2f39c10d2f --- /dev/null +++ b/codex-rs/core/src/mentions.rs @@ -0,0 +1,64 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; + +use codex_protocol::user_input::UserInput; + +use crate::connectors; +use crate::skills::SkillMetadata; +use crate::skills::injection::extract_tool_mentions; + +pub(crate) struct CollectedToolMentions { + pub(crate) plain_names: HashSet, + pub(crate) paths: HashSet, +} + +pub(crate) fn collect_tool_mentions_from_messages(messages: &[String]) -> CollectedToolMentions { + let mut plain_names = HashSet::new(); + let mut paths = HashSet::new(); + for message in messages { + let mentions = extract_tool_mentions(message); + plain_names.extend(mentions.plain_names().map(str::to_string)); + paths.extend(mentions.paths().map(str::to_string)); + } + CollectedToolMentions { plain_names, paths } +} + +pub(crate) fn collect_explicit_app_paths(input: &[UserInput]) -> Vec { + input + .iter() + .filter_map(|item| match item { + UserInput::Mention { path, .. } => Some(path.clone()), + _ => None, + }) + .collect() +} + +pub(crate) fn build_skill_name_counts( + skills: &[SkillMetadata], + disabled_paths: &HashSet, +) -> (HashMap, HashMap) { + let mut exact_counts: HashMap = HashMap::new(); + let mut lower_counts: HashMap = HashMap::new(); + for skill in skills { + if disabled_paths.contains(&skill.path) { + continue; + } + *exact_counts.entry(skill.name.clone()).or_insert(0) += 1; + *lower_counts + .entry(skill.name.to_ascii_lowercase()) + .or_insert(0) += 1; + } + (exact_counts, lower_counts) +} + +pub(crate) fn build_connector_slug_counts( + connectors: &[connectors::AppInfo], +) -> HashMap { + let mut counts: HashMap = HashMap::new(); + for connector in connectors { + let slug = connectors::connector_mention_slug(connector); + *counts.entry(slug).or_insert(0) += 1; + } + counts +} diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index 65ec4dd510..57de457e90 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; @@ -67,7 +68,8 @@ fn emit_skill_injected_metric(otel: Option<&OtelManager>, skill: &SkillMetadata, /// Collect explicitly mentioned skills from `$name` text mentions. /// /// Text inputs are scanned once to extract `$skill-name` tokens, then we iterate `skills` -/// in their existing order to preserve prior ordering semantics. +/// in their existing order to preserve prior ordering semantics. Explicit links are +/// resolved by path and plain names are only used when the match is unambiguous. /// /// Complexity: `O(S + T + N_t * S)` time, `O(S)` space, where: /// `S` = number of skills, `T` = total text length, `N_t` = number of text inputs. @@ -75,17 +77,24 @@ pub(crate) fn collect_explicit_skill_mentions( inputs: &[UserInput], skills: &[SkillMetadata], disabled_paths: &HashSet, + skill_name_counts: &HashMap, + connector_slug_counts: &HashMap, ) -> Vec { + let selection_context = SkillSelectionContext { + skills, + disabled_paths, + skill_name_counts, + connector_slug_counts, + }; let mut selected: Vec = Vec::new(); let mut seen_names: HashSet = HashSet::new(); let mut seen_paths: HashSet = HashSet::new(); for input in inputs { if let UserInput::Text { text, .. } = input { - let mentioned_names = extract_skill_mentions(text); + let mentioned_names = extract_tool_mentions(text); select_skills_from_mentions( - skills, - disabled_paths, + &selection_context, &mentioned_names, &mut seen_names, &mut seen_paths, @@ -97,36 +106,95 @@ pub(crate) fn collect_explicit_skill_mentions( selected } -struct SkillMentions<'a> { - names: HashSet<&'a str>, - paths: HashSet<&'a str>, +struct SkillSelectionContext<'a> { + skills: &'a [SkillMetadata], + disabled_paths: &'a HashSet, + skill_name_counts: &'a HashMap, + connector_slug_counts: &'a HashMap, } -impl<'a> SkillMentions<'a> { +pub(crate) struct ToolMentions<'a> { + names: HashSet<&'a str>, + paths: HashSet<&'a str>, + plain_names: HashSet<&'a str>, +} + +impl<'a> ToolMentions<'a> { fn is_empty(&self) -> bool { self.names.is_empty() && self.paths.is_empty() } + + pub(crate) fn plain_names(&self) -> impl Iterator + '_ { + self.plain_names.iter().copied() + } + + pub(crate) fn paths(&self) -> impl Iterator + '_ { + self.paths.iter().copied() + } } -/// Extract `$skill-name` mentions from a single text input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToolMentionKind { + App, + Mcp, + Skill, + Other, +} + +const APP_PATH_PREFIX: &str = "app://"; +const MCP_PATH_PREFIX: &str = "mcp://"; +const SKILL_PATH_PREFIX: &str = "skill://"; +const SKILL_FILENAME: &str = "SKILL.md"; + +pub(crate) fn tool_kind_for_path(path: &str) -> ToolMentionKind { + if path.starts_with(APP_PATH_PREFIX) { + ToolMentionKind::App + } else if path.starts_with(MCP_PATH_PREFIX) { + ToolMentionKind::Mcp + } else if path.starts_with(SKILL_PATH_PREFIX) || is_skill_filename(path) { + ToolMentionKind::Skill + } else { + ToolMentionKind::Other + } +} + +fn is_skill_filename(path: &str) -> bool { + let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path); + file_name.eq_ignore_ascii_case(SKILL_FILENAME) +} + +pub(crate) fn app_id_from_path(path: &str) -> Option<&str> { + path.strip_prefix(APP_PATH_PREFIX) + .filter(|value| !value.is_empty()) +} + +pub(crate) fn normalize_skill_path(path: &str) -> &str { + path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path) +} + +/// Extract `$tool-name` mentions from a single text input. /// -/// Supports explicit resource links in the form `[$skill-name](resource path)`. When a +/// Supports explicit resource links in the form `[$tool-name](resource path)`. When a /// resource path is present, it is captured for exact path matching while also tracking /// the name for fallback matching. -fn extract_skill_mentions(text: &str) -> SkillMentions<'_> { +pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> { let text_bytes = text.as_bytes(); let mut mentioned_names: HashSet<&str> = HashSet::new(); let mut mentioned_paths: HashSet<&str> = HashSet::new(); + let mut plain_names: HashSet<&str> = HashSet::new(); let mut index = 0; while index < text_bytes.len() { let byte = text_bytes[index]; if byte == b'[' && let Some((name, path, end_index)) = - parse_linked_skill_mention(text, text_bytes, index) + parse_linked_tool_mention(text, text_bytes, index) { if !is_common_env_var(name) { - mentioned_names.insert(name); + let kind = tool_kind_for_path(path); + if !matches!(kind, ToolMentionKind::App | ToolMentionKind::Mcp) { + mentioned_names.insert(name); + } mentioned_paths.insert(path); } index = end_index; @@ -143,14 +211,14 @@ fn extract_skill_mentions(text: &str) -> SkillMentions<'_> { index += 1; continue; }; - if !is_skill_name_char(*first_name_byte) { + if !is_mention_name_char(*first_name_byte) { index += 1; continue; } let mut name_end = name_start + 1; while let Some(next_byte) = text_bytes.get(name_end) - && is_skill_name_char(*next_byte) + && is_mention_name_char(*next_byte) { name_end += 1; } @@ -158,21 +226,22 @@ fn extract_skill_mentions(text: &str) -> SkillMentions<'_> { let name = &text[name_start..name_end]; if !is_common_env_var(name) { mentioned_names.insert(name); + plain_names.insert(name); } index = name_end; } - SkillMentions { + ToolMentions { names: mentioned_names, paths: mentioned_paths, + plain_names, } } /// Select mentioned skills while preserving the order of `skills`. fn select_skills_from_mentions( - skills: &[SkillMetadata], - disabled_paths: &HashSet, - mentions: &SkillMentions<'_>, + selection_context: &SkillSelectionContext<'_>, + mentions: &ToolMentions<'_>, seen_names: &mut HashSet, seen_paths: &mut HashSet, selected: &mut Vec, @@ -181,32 +250,65 @@ fn select_skills_from_mentions( return; } - for skill in skills { - if disabled_paths.contains(&skill.path) || seen_paths.contains(&skill.path) { + let mention_skill_paths: HashSet<&str> = mentions + .paths() + .filter(|path| { + !matches!( + tool_kind_for_path(path), + ToolMentionKind::App | ToolMentionKind::Mcp + ) + }) + .map(normalize_skill_path) + .collect(); + + for skill in selection_context.skills { + if selection_context.disabled_paths.contains(&skill.path) + || seen_paths.contains(&skill.path) + { continue; } let path_str = skill.path.to_string_lossy(); - if mentions.paths.contains(path_str.as_ref()) { + if mention_skill_paths.contains(path_str.as_ref()) { seen_paths.insert(skill.path.clone()); seen_names.insert(skill.name.clone()); selected.push(skill.clone()); } } - for skill in skills { - if disabled_paths.contains(&skill.path) || seen_paths.contains(&skill.path) { + for skill in selection_context.skills { + if selection_context.disabled_paths.contains(&skill.path) + || seen_paths.contains(&skill.path) + { continue; } - if mentions.names.contains(skill.name.as_str()) && seen_names.insert(skill.name.clone()) { + if !mentions.plain_names.contains(skill.name.as_str()) { + continue; + } + + let skill_count = selection_context + .skill_name_counts + .get(skill.name.as_str()) + .copied() + .unwrap_or(0); + let connector_count = selection_context + .connector_slug_counts + .get(&skill.name.to_ascii_lowercase()) + .copied() + .unwrap_or(0); + if skill_count != 1 || connector_count != 0 { + continue; + } + + if seen_names.insert(skill.name.clone()) { seen_paths.insert(skill.path.clone()); selected.push(skill.clone()); } } } -fn parse_linked_skill_mention<'a>( +fn parse_linked_tool_mention<'a>( text: &'a str, text_bytes: &[u8], start: usize, @@ -218,13 +320,13 @@ fn parse_linked_skill_mention<'a>( let name_start = dollar_index + 1; let first_name_byte = text_bytes.get(name_start)?; - if !is_skill_name_char(*first_name_byte) { + if !is_mention_name_char(*first_name_byte) { return None; } let mut name_end = name_start + 1; while let Some(next_byte) = text_bytes.get(name_end) - && is_skill_name_char(*next_byte) + && is_mention_name_char(*next_byte) { name_end += 1; } @@ -304,7 +406,7 @@ fn text_mentions_skill(text: &str, skill_name: &str) -> bool { let after_index = name_start + skill_bytes.len(); let after = text_bytes.get(after_index).copied(); - if after.is_none_or(|b| !is_skill_name_char(b)) { + if after.is_none_or(|b| !is_mention_name_char(b)) { return true; } } @@ -312,7 +414,7 @@ fn text_mentions_skill(text: &str, skill_name: &str) -> bool { false } -fn is_skill_name_char(byte: u8) -> bool { +fn is_mention_name_char(byte: u8) -> bool { matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') } @@ -320,6 +422,7 @@ fn is_skill_name_char(byte: u8) -> bool { mod tests { use super::*; use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::collections::HashSet; fn make_skill(name: &str, path: &str) -> SkillMetadata { @@ -339,11 +442,41 @@ mod tests { } fn assert_mentions(text: &str, expected_names: &[&str], expected_paths: &[&str]) { - let mentions = extract_skill_mentions(text); + let mentions = extract_tool_mentions(text); assert_eq!(mentions.names, set(expected_names)); assert_eq!(mentions.paths, set(expected_paths)); } + fn build_skill_name_counts( + skills: &[SkillMetadata], + disabled_paths: &HashSet, + ) -> HashMap { + let mut counts = HashMap::new(); + for skill in skills { + if disabled_paths.contains(&skill.path) { + continue; + } + *counts.entry(skill.name.clone()).or_insert(0) += 1; + } + counts + } + + fn collect_mentions( + inputs: &[UserInput], + skills: &[SkillMetadata], + disabled_paths: &HashSet, + connector_slug_counts: &HashMap, + ) -> Vec { + let skill_name_counts = build_skill_name_counts(skills, disabled_paths); + collect_explicit_skill_mentions( + inputs, + skills, + disabled_paths, + &skill_name_counts, + connector_slug_counts, + ) + } + #[test] fn text_mentions_skill_requires_exact_boundary() { assert_eq!( @@ -386,7 +519,7 @@ mod tests { } #[test] - fn extract_skill_mentions_handles_plain_and_linked_mentions() { + fn extract_tool_mentions_handles_plain_and_linked_mentions() { assert_mentions( "use $alpha and [$beta](/tmp/beta)", &["alpha", "beta"], @@ -395,26 +528,26 @@ mod tests { } #[test] - fn extract_skill_mentions_skips_common_env_vars() { + fn extract_tool_mentions_skips_common_env_vars() { assert_mentions("use $PATH and $alpha", &["alpha"], &[]); assert_mentions("use [$HOME](/tmp/skill)", &[], &[]); assert_mentions("use $XDG_CONFIG_HOME and $beta", &["beta"], &[]); } #[test] - fn extract_skill_mentions_requires_link_syntax() { + fn extract_tool_mentions_requires_link_syntax() { assert_mentions("[beta](/tmp/beta)", &[], &[]); assert_mentions("[$beta] /tmp/beta", &["beta"], &[]); assert_mentions("[$beta]()", &["beta"], &[]); } #[test] - fn extract_skill_mentions_trims_linked_paths_and_allows_spacing() { + fn extract_tool_mentions_trims_linked_paths_and_allows_spacing() { assert_mentions("use [$beta] ( /tmp/beta )", &["beta"], &["/tmp/beta"]); } #[test] - fn extract_skill_mentions_stops_at_non_name_chars() { + fn extract_tool_mentions_stops_at_non_name_chars() { assert_mentions( "use $alpha.skill and $beta_extra", &["alpha", "beta_extra"], @@ -431,8 +564,9 @@ mod tests { text: "first $alpha-skill then $beta-skill".to_string(), text_elements: Vec::new(), }]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); // Text scanning should not change the previous selection ordering semantics. assert_eq!(selected, vec![beta, alpha]); @@ -453,8 +587,9 @@ mod tests { path: PathBuf::from("/tmp/beta"), }, ]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); assert_eq!(selected, vec![alpha]); } @@ -467,25 +602,27 @@ mod tests { text: "use [$alpha-skill](/tmp/alpha) and [$alpha-skill](/tmp/alpha)".to_string(), text_elements: Vec::new(), }]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); assert_eq!(selected, vec![alpha]); } #[test] - fn collect_explicit_skill_mentions_dedupes_by_name() { + fn collect_explicit_skill_mentions_skips_ambiguous_name() { let alpha = make_skill("demo-skill", "/tmp/alpha"); let beta = make_skill("demo-skill", "/tmp/beta"); - let skills = vec![alpha.clone(), beta]; + let skills = vec![alpha, beta]; let inputs = vec![UserInput::Text { text: "use $demo-skill and again $demo-skill".to_string(), text_elements: Vec::new(), }]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - assert_eq!(selected, vec![alpha]); + assert_eq!(selected, Vec::new()); } #[test] @@ -497,26 +634,58 @@ mod tests { text: "use $demo-skill and [$demo-skill](/tmp/beta)".to_string(), text_elements: Vec::new(), }]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); assert_eq!(selected, vec![beta]); } #[test] - fn collect_explicit_skill_mentions_falls_back_when_linked_path_disabled() { + fn collect_explicit_skill_mentions_skips_plain_name_when_connector_matches() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha]; + let inputs = vec![UserInput::Text { + text: "use $alpha-skill".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); + } + + #[test] + fn collect_explicit_skill_mentions_allows_explicit_path_with_connector_conflict() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha.clone()]; + let inputs = vec![UserInput::Text { + text: "use [$alpha-skill](/tmp/alpha)".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, vec![alpha]); + } + + #[test] + fn collect_explicit_skill_mentions_skips_when_linked_path_disabled() { let alpha = make_skill("demo-skill", "/tmp/alpha"); let beta = make_skill("demo-skill", "/tmp/beta"); - let skills = vec![alpha, beta.clone()]; + let skills = vec![alpha, beta]; let inputs = vec![UserInput::Text { text: "use [$demo-skill](/tmp/alpha)".to_string(), text_elements: Vec::new(), }]; let disabled = HashSet::from([PathBuf::from("/tmp/alpha")]); + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &disabled); + let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts); - assert_eq!(selected, vec![beta]); + assert_eq!(selected, Vec::new()); } #[test] @@ -528,24 +697,41 @@ mod tests { text: "use [$demo-skill](/tmp/beta)".to_string(), text_elements: Vec::new(), }]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); assert_eq!(selected, vec![beta]); } #[test] - fn collect_explicit_skill_mentions_falls_back_to_name_when_path_missing() { + fn collect_explicit_skill_mentions_skips_missing_path_with_no_fallback() { let alpha = make_skill("demo-skill", "/tmp/alpha"); let beta = make_skill("demo-skill", "/tmp/beta"); - let skills = vec![alpha.clone(), beta]; + let skills = vec![alpha, beta]; let inputs = vec![UserInput::Text { text: "use [$demo-skill](/tmp/missing)".to_string(), text_elements: Vec::new(), }]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - assert_eq!(selected, vec![alpha]); + assert_eq!(selected, Vec::new()); + } + + #[test] + fn collect_explicit_skill_mentions_skips_missing_path_without_fallback() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let skills = vec![alpha]; + let inputs = vec![UserInput::Text { + text: "use [$demo-skill](/tmp/missing)".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); } } diff --git a/codex-rs/core/src/token_data.rs b/codex-rs/core/src/token_data.rs index 526e240a6c..744cc73d9b 100644 --- a/codex-rs/core/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -42,6 +42,15 @@ impl IdTokenInfo { PlanType::Unknown(s) => s.clone(), }) } + + pub fn is_workspace_account(&self) -> bool { + matches!( + self.chatgpt_plan_type, + Some(PlanType::Known( + KnownPlan::Team | KnownPlan::Business | KnownPlan::Enterprise | KnownPlan::Edu + )) + ) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -140,6 +149,7 @@ where #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use serde::Serialize; #[test] @@ -200,4 +210,19 @@ mod tests { assert!(info.email.is_none()); assert!(info.get_chatgpt_plan_type().is_none()); } + + #[test] + fn workspace_account_detection_matches_workspace_plans() { + let workspace = IdTokenInfo { + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Business)), + ..IdTokenInfo::default() + }; + assert_eq!(workspace.is_workspace_account(), true); + + let personal = IdTokenInfo { + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + ..IdTokenInfo::default() + }; + assert_eq!(personal.is_workspace_account(), false); + } } diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index ee49cac0a6..8cdc5c9575 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -82,6 +82,15 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `EventMsg::TurnComplete` – Contains a `response_id` bookmark for last `response_id` executed by the turn. This can be used to continue the turn at a later point in time, perhaps with additional user input. - `EventMsg::ListSkillsResponse` – Response payload with per-cwd skill entries (`cwd`, `skills`, `errors`) +### UserInput items + +`Op::UserTurn` content items can include: + +- `text` – Plain text plus optional UI text elements. +- `image` / `local_image` – Image inputs. +- `skill` – Explicit skill selection (`name`, `path` to `SKILL.md`). +- `mention` – Explicit app/connector selection (`name`, `path` in `app://{connector_id}` form). + Note: For v1 wire compatibility, `EventMsg::TurnStarted` and `EventMsg::TurnComplete` serialize as `task_started` / `task_complete`. The deserializer accepts both `task_*` and `turn_*` tags. The `response_id` returned from each turn matches the OpenAI `response_id` stored in the API's `/responses` endpoint. It can be stored and used in future `Sessions` to resume threads of work. diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 50e8ee342f..759ff5aae4 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -674,7 +674,7 @@ impl From> for ResponseInputItem { image_index += 1; local_image_content_items_with_label_number(&path, Some(image_index)) } - UserInput::Skill { .. } => Vec::new(), // Skill bodies are injected later in core + UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(), // Tool bodies are injected later in core }) .collect::>(), } diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index e9ca09580e..d40511f342 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -29,6 +29,8 @@ pub enum UserInput { name: String, path: std::path::PathBuf, }, + /// Explicit mention selected by the user (name + app://connector id). + Mention { name: String, path: String }, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS, JsonSchema)] diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 1d20542365..820fe20e93 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -30,6 +30,7 @@ codex-ansi-escape = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-backend-client = { workspace = true } +codex-chatgpt = { workspace = true } codex-common = { workspace = true, features = [ "cli", "elapsed", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e5db8fc6b6..ae1dc4b322 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1508,6 +1508,21 @@ impl App { )); tui.frame_requester().schedule_frame(); } + AppEvent::OpenAppLink { + title, + description, + instructions, + url, + is_installed, + } => { + self.chat_widget.open_app_link_view( + title, + description, + instructions, + url, + is_installed, + ); + } AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); } @@ -1517,6 +1532,9 @@ impl App { AppEvent::RateLimitSnapshotFetched(snapshot) => { self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); } + AppEvent::ConnectorsLoaded(result) => { + self.chat_widget.on_connectors_loaded(result); + } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b65d1efb62..bbd228e114 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; +use codex_chatgpt::connectors::AppInfo; use codex_common::approval_presets::ApprovalPreset; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; @@ -40,6 +41,11 @@ pub(crate) enum WindowsSandboxFallbackReason { ElevationFailed, } +#[derive(Debug, Clone)] +pub(crate) struct ConnectorsSnapshot { + pub(crate) connectors: Vec, +} + #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub(crate) enum AppEvent { @@ -89,9 +95,21 @@ pub(crate) enum AppEvent { /// Result of refreshing rate limits RateLimitSnapshotFetched(RateLimitSnapshot), + /// Result of prefetching connectors. + ConnectorsLoaded(Result), + /// Result of computing a `/diff` command. DiffResult(String), + /// Open the app link view in the bottom pane. + OpenAppLink { + title: String, + description: Option, + instructions: String, + url: String, + is_installed: bool, + }, + InsertHistoryCell(Box), StartCommitAnimation, diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs new file mode 100644 index 0000000000..1f672607a2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -0,0 +1,163 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Block; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use textwrap::wrap; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use crate::key_hint; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::style::user_message_style; +use crate::wrapping::word_wrap_lines; + +pub(crate) struct AppLinkView { + title: String, + description: Option, + instructions: String, + url: String, + is_installed: bool, + complete: bool, +} + +impl AppLinkView { + pub(crate) fn new( + title: String, + description: Option, + instructions: String, + url: String, + is_installed: bool, + ) -> Self { + Self { + title, + description, + instructions, + url, + is_installed, + complete: false, + } + } + + fn content_lines(&self, width: u16) -> Vec> { + let usable_width = width.max(1) as usize; + let mut lines: Vec> = Vec::new(); + + lines.push(Line::from(self.title.clone().bold())); + if let Some(description) = self + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + { + for line in wrap(description, usable_width) { + lines.push(Line::from(line.into_owned().dim())); + } + } + lines.push(Line::from("")); + if self.is_installed { + for line in wrap("Use $ to insert this app into the prompt.", usable_width) { + lines.push(Line::from(line.into_owned())); + } + lines.push(Line::from("")); + } + + let instructions = self.instructions.trim(); + if !instructions.is_empty() { + for line in wrap(instructions, usable_width) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Newly installed apps can take a few minutes to appear in /apps.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + if !self.is_installed { + for line in wrap( + "After installed, use $ to insert this app into the prompt.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } + lines.push(Line::from("")); + } + + lines.push(Line::from(vec!["Open:".dim()])); + let url_line = Line::from(vec![self.url.clone().cyan().underlined()]); + lines.extend(word_wrap_lines(vec![url_line], usable_width)); + + lines + } +} + +impl BottomPaneView for AppLinkView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if let KeyEvent { + code: KeyCode::Esc, .. + } = key_event + { + self.on_ctrl_c(); + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } +} + +impl crate::render::renderable::Renderable for AppLinkView { + fn desired_height(&self, width: u16) -> u16 { + let content_width = width.saturating_sub(4).max(1); + let content_lines = self.content_lines(content_width); + content_lines.len() as u16 + 3 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + Block::default() + .style(user_message_style()) + .render(area, buf); + + let [content_area, hint_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + let inner = content_area.inset(Insets::vh(1, 2)); + let content_width = inner.width.max(1); + let lines = self.content_lines(content_width); + Paragraph::new(lines).render(inner, buf); + + if hint_area.height > 0 { + let hint_area = Rect { + x: hint_area.x.saturating_add(2), + y: hint_area.y, + width: hint_area.width.saturating_sub(2), + height: hint_area.height, + }; + hint_line().dim().render(hint_area, buf); + } + } +} + +fn hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to close".into(), + ]) +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3a3f5d407b..9d0cbf22e5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3,7 +3,7 @@ //! It is responsible for: //! //! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments. -//! - Routing keys to the active popup (slash commands, file search, skill mentions). +//! - Routing keys to the active popup (slash commands, file search, skill/apps mentions). //! - Handling submit vs newline on Enter. //! - Turning raw key streams into explicit paste operations on platforms where terminals //! don't provide reliable bracketed paste (notably Windows). @@ -124,6 +124,7 @@ use super::footer::single_line_footer_layout; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; +use super::skill_popup::MentionItem; use super::skill_popup::SkillPopup; use super::slash_commands; use crate::bottom_pane::paste_burst::FlushResult; @@ -146,6 +147,7 @@ use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use crate::app_event::AppEvent; +use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::textarea::TextArea; @@ -154,6 +156,8 @@ use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; use crate::history_cell; use crate::ui_consts::LIVE_PREFIX_COLS; +use codex_chatgpt::connectors; +use codex_chatgpt::connectors::AppInfo; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use std::cell::RefCell; @@ -276,12 +280,15 @@ pub(crate) struct ChatComposer { context_window_percent: Option, context_window_used_tokens: Option, skills: Option>, - dismissed_skill_popup_token: Option, + connectors_snapshot: Option, + dismissed_mention_popup_token: Option, + mention_paths: HashMap, /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, collaboration_modes_enabled: bool, config: ChatComposerConfig, collaboration_mode_indicator: Option, + connectors_enabled: bool, personality_command_enabled: bool, windows_degraded_sandbox_active: bool, } @@ -363,11 +370,14 @@ impl ChatComposer { context_window_percent: None, context_window_used_tokens: None, skills: None, - dismissed_skill_popup_token: None, + connectors_snapshot: None, + dismissed_mention_popup_token: None, + mention_paths: HashMap::new(), steer_enabled: false, collaboration_modes_enabled: false, config, collaboration_mode_indicator: None, + connectors_enabled: false, personality_command_enabled: false, windows_degraded_sandbox_active: false, }; @@ -380,6 +390,14 @@ impl ChatComposer { self.skills = skills; } + pub fn set_connector_mentions(&mut self, connectors_snapshot: Option) { + self.connectors_snapshot = connectors_snapshot; + } + + pub(crate) fn take_mention_paths(&mut self) -> HashMap { + std::mem::take(&mut self.mention_paths) + } + /// Enables or disables "Steer" behavior for submission keys. /// /// When steer is enabled, `Enter` produces [`InputResult::Submitted`] (send immediately) and @@ -394,6 +412,10 @@ impl ChatComposer { self.collaboration_modes_enabled = enabled; } + pub fn set_connectors_enabled(&mut self, enabled: bool) { + self.connectors_enabled = enabled; + } + pub fn set_collaboration_mode_indicator( &mut self, indicator: Option, @@ -697,6 +719,7 @@ impl ChatComposer { self.textarea.set_text_clearing_elements(""); self.pending_pastes.clear(); self.attached_images.clear(); + self.mention_paths.clear(); self.textarea.set_text_with_elements(&text, &text_elements); @@ -1349,7 +1372,10 @@ impl ChatComposer { unreachable!(); }; - match key_event { + let mut selected_mention: Option<(String, Option)> = None; + let mut close_popup = false; + + let result = match key_event { KeyEvent { code: KeyCode::Up, .. } @@ -1376,8 +1402,8 @@ impl ChatComposer { KeyEvent { code: KeyCode::Esc, .. } => { - if let Some(tok) = self.current_skill_token() { - self.dismissed_skill_popup_token = Some(tok); + if let Some(tok) = self.current_mention_token() { + self.dismissed_mention_popup_token = Some(tok); } self.active_popup = ActivePopup::None; (InputResult::None, true) @@ -1390,15 +1416,26 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - let selected = popup.selected_skill().map(|skill| skill.name.clone()); - if let Some(name) = selected { - self.insert_selected_skill(&name); + if let Some(mention) = popup.selected_mention() { + selected_mention = Some((mention.insert_text.clone(), mention.path.clone())); } - self.active_popup = ActivePopup::None; + close_popup = true; (InputResult::None, true) } input => self.handle_input_basic(input), + }; + + if close_popup { + if let Some((insert_text, path)) = selected_mention { + if let Some(path) = path.as_deref() { + self.record_mention_path(&insert_text, path); + } + self.insert_selected_mention(&insert_text); + } + self.active_popup = ActivePopup::None; } + + result } fn is_image_path(path: &str) -> bool { @@ -1511,14 +1548,23 @@ impl ChatComposer { (rebuilt, rebuilt_elements) } - fn skills_enabled(&self) -> bool { - self.skills.as_ref().is_some_and(|s| !s.is_empty()) - } - pub fn skills(&self) -> Option<&Vec> { self.skills.as_ref() } + fn mentions_enabled(&self) -> bool { + let skills_ready = self + .skills + .as_ref() + .is_some_and(|skills| !skills.is_empty()); + let connectors_ready = self.connectors_enabled + && self + .connectors_snapshot + .as_ref() + .is_some_and(|snapshot| !snapshot.connectors.is_empty()); + skills_ready || connectors_ready + } + /// Extract a token prefixed with `prefix` under the cursor, if any. /// /// The returned string **does not** include the prefix. @@ -1632,8 +1678,8 @@ impl ChatComposer { Self::current_prefixed_token(textarea, '@', false) } - fn current_skill_token(&self) -> Option { - if !self.skills_enabled() { + fn current_mention_token(&self) -> Option { + if !self.mentions_enabled() { return None; } Self::current_prefixed_token(&self.textarea, '$', true) @@ -1691,7 +1737,7 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } - fn insert_selected_skill(&mut self, skill_name: &str) { + fn insert_selected_mention(&mut self, insert_text: &str) { let cursor_offset = self.textarea.cursor(); let text = self.textarea.text(); let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); @@ -1712,7 +1758,7 @@ impl ChatComposer { .unwrap_or(after_cursor.len()); let end_idx = safe_cursor + end_rel_idx; - let inserted = format!("${skill_name}"); + let inserted = insert_text.to_string(); let mut new_text = String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); @@ -1721,12 +1767,35 @@ impl ChatComposer { new_text.push(' '); new_text.push_str(&text[end_idx..]); - // Skill insertion rebuilds plain text, so drop existing elements. + // Mention insertion rebuilds plain text, so drop existing elements. self.textarea.set_text_clearing_elements(&new_text); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } + fn record_mention_path(&mut self, insert_text: &str, path: &str) { + let Some(name) = Self::mention_name_from_insert_text(insert_text) else { + return; + }; + self.mention_paths.insert(name, path.to_string()); + } + + fn mention_name_from_insert_text(insert_text: &str) -> Option { + let name = insert_text.strip_prefix('$')?; + if name.is_empty() { + return None; + } + if name + .as_bytes() + .iter() + .all(|byte| is_mention_name_char(*byte)) + { + Some(name.to_string()) + } else { + None + } + } + /// Prepare text for submission/queuing. Returns None if submission should be suppressed. /// On success, clears pending paste payloads because placeholders have been expanded. fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { @@ -1765,6 +1834,7 @@ impl ChatComposer { let is_builtin = slash_commands::find_builtin_command( name, self.collaboration_modes_enabled, + self.connectors_enabled, self.personality_command_enabled, self.windows_degraded_sandbox_active, ) @@ -1951,6 +2021,7 @@ impl ChatComposer { && let Some(cmd) = slash_commands::find_builtin_command( name, self.collaboration_modes_enabled, + self.connectors_enabled, self.personality_command_enabled, self.windows_degraded_sandbox_active, ) @@ -1979,6 +2050,7 @@ impl ChatComposer { && let Some(cmd) = slash_commands::find_builtin_command( name, self.collaboration_modes_enabled, + self.connectors_enabled, self.personality_command_enabled, self.windows_degraded_sandbox_active, ) @@ -2388,10 +2460,10 @@ impl ChatComposer { self.active_popup = ActivePopup::None; return; } - let skill_token = self.current_skill_token(); + let mention_token = self.current_mention_token(); let allow_command_popup = - self.slash_commands_enabled() && file_token.is_none() && skill_token.is_none(); + self.slash_commands_enabled() && file_token.is_none() && mention_token.is_none(); self.sync_command_popup(allow_command_popup); if matches!(self.active_popup, ActivePopup::Command(_)) { @@ -2401,20 +2473,20 @@ impl ChatComposer { self.current_file_query = None; } self.dismissed_file_popup_token = None; - self.dismissed_skill_popup_token = None; + self.dismissed_mention_popup_token = None; return; } - if let Some(token) = skill_token { + if let Some(token) = mention_token { if self.current_file_query.is_some() { self.app_event_tx .send(AppEvent::StartFileSearch(String::new())); self.current_file_query = None; } - self.sync_skill_popup(token); + self.sync_mention_popup(token); return; } - self.dismissed_skill_popup_token = None; + self.dismissed_mention_popup_token = None; if let Some(token) = file_token { self.sync_file_search_popup(token); @@ -2477,6 +2549,7 @@ impl ChatComposer { if slash_commands::has_builtin_prefix( name, self.collaboration_modes_enabled, + self.connectors_enabled, self.personality_command_enabled, self.windows_degraded_sandbox_active, ) { @@ -2529,11 +2602,13 @@ impl ChatComposer { _ => { if is_editing_slash_command_name { let collaboration_modes_enabled = self.collaboration_modes_enabled; + let connectors_enabled = self.connectors_enabled; let personality_command_enabled = self.personality_command_enabled; let mut command_popup = CommandPopup::new( self.custom_prompts.clone(), CommandPopupFlags { collaboration_modes_enabled, + connectors_enabled, personality_command_enabled, windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, }, @@ -2594,32 +2669,99 @@ impl ChatComposer { self.dismissed_file_popup_token = None; } - fn sync_skill_popup(&mut self, query: String) { - if self.dismissed_skill_popup_token.as_ref() == Some(&query) { + fn sync_mention_popup(&mut self, query: String) { + if self.dismissed_mention_popup_token.as_ref() == Some(&query) { return; } - let skills = match self.skills.as_ref() { - Some(skills) if !skills.is_empty() => skills.clone(), - _ => { - self.active_popup = ActivePopup::None; - return; - } - }; + let mentions = self.mention_items(); + if mentions.is_empty() { + self.active_popup = ActivePopup::None; + return; + } match &mut self.active_popup { ActivePopup::Skill(popup) => { popup.set_query(&query); - popup.set_skills(skills); + popup.set_mentions(mentions); } _ => { - let mut popup = SkillPopup::new(skills); + let mut popup = SkillPopup::new(mentions); popup.set_query(&query); self.active_popup = ActivePopup::Skill(popup); } } } + fn mention_items(&self) -> Vec { + let mut mentions = Vec::new(); + + if let Some(skills) = self.skills.as_ref() { + for skill in skills { + let display_name = skill_display_name(skill).to_string(); + let description = skill_description(skill); + let skill_name = skill.name.clone(); + let search_terms = if display_name == skill.name { + vec![skill_name.clone()] + } else { + vec![skill_name.clone(), display_name.clone()] + }; + mentions.push(MentionItem { + display_name, + description, + insert_text: format!("${skill_name}"), + search_terms, + path: Some(skill.path.to_string_lossy().into_owned()), + }); + } + } + + if self.connectors_enabled + && let Some(snapshot) = self.connectors_snapshot.as_ref() + { + for connector in &snapshot.connectors { + if !connector.is_accessible { + 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(); + mentions.push(MentionItem { + display_name: display_name.clone(), + description, + insert_text: format!("${slug}"), + search_terms, + path: Some(format!("app://{connector_id}")), + }); + } + } + + mentions + } + + fn connector_brief_description(connector: &AppInfo) -> String { + let status_label = if connector.is_accessible { + "Connected" + } else { + "Can be installed" + }; + match Self::connector_description(connector) { + Some(description) => format!("{status_label} - {description}"), + None => status_label.to_string(), + } + } + + fn connector_description(connector: &AppInfo) -> Option { + connector + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) + } + fn set_has_focus(&mut self, has_focus: bool) { self.has_focus = has_focus; } @@ -2658,6 +2800,29 @@ impl ChatComposer { } } +fn skill_display_name(skill: &SkillMetadata) -> &str { + skill + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .unwrap_or(&skill.name) +} + +fn skill_description(skill: &SkillMetadata) -> Option { + let description = skill + .interface + .as_ref() + .and_then(|interface| interface.short_description.as_deref()) + .or(skill.short_description.as_deref()) + .unwrap_or(&skill.description); + let trimmed = description.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +fn is_mention_name_char(byte: u8) -> bool { + matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') +} + impl Renderable for ChatComposer { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { if !self.input_enabled { @@ -2938,6 +3103,7 @@ mod tests { use tempfile::tempdir; use crate::app_event::AppEvent; + use crate::bottom_pane::AppEventSender; use crate::bottom_pane::ChatComposer; use crate::bottom_pane::InputResult; diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index c1cdd5374d..5423231db8 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -32,6 +32,7 @@ pub(crate) struct CommandPopup { #[derive(Clone, Copy, Debug, Default)] pub(crate) struct CommandPopupFlags { pub(crate) collaboration_modes_enabled: bool, + pub(crate) connectors_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) windows_degraded_sandbox_active: bool, } @@ -41,6 +42,7 @@ impl CommandPopup { // Keep built-in availability in sync with the composer. let builtins = slash_commands::builtins_for_input( flags.collaboration_modes_enabled, + flags.connectors_enabled, flags.personality_command_enabled, flags.windows_degraded_sandbox_active, ); @@ -478,6 +480,7 @@ mod tests { Vec::new(), CommandPopupFlags { collaboration_modes_enabled: true, + connectors_enabled: false, personality_command_enabled: true, windows_degraded_sandbox_active: false, }, @@ -496,6 +499,7 @@ mod tests { Vec::new(), CommandPopupFlags { collaboration_modes_enabled: true, + connectors_enabled: false, personality_command_enabled: false, windows_degraded_sandbox_active: false, }, @@ -522,6 +526,7 @@ mod tests { Vec::new(), CommandPopupFlags { collaboration_modes_enabled: true, + connectors_enabled: false, personality_command_enabled: true, windows_degraded_sandbox_active: false, }, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 701e0762cb..d08ccf5068 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -13,8 +13,10 @@ //! //! Some UI is time-based rather than input-based, such as the transient "press again to quit" //! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. +use std::collections::HashMap; use std::path::PathBuf; +use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::queued_user_messages::QueuedUserMessages; use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; @@ -36,8 +38,10 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use std::time::Duration; +mod app_link_view; mod approval_overlay; mod request_user_input; +pub(crate) use app_link_view::AppLinkView; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; pub(crate) use request_user_input::RequestUserInputOverlay; @@ -204,6 +208,15 @@ impl BottomPane { self.request_redraw(); } + pub fn set_connectors_snapshot(&mut self, snapshot: Option) { + self.composer.set_connector_mentions(snapshot); + self.request_redraw(); + } + + pub fn take_mention_paths(&mut self) -> HashMap { + self.composer.take_mention_paths() + } + pub fn set_steer_enabled(&mut self, enabled: bool) { self.composer.set_steer_enabled(enabled); } @@ -213,6 +226,10 @@ impl BottomPane { self.request_redraw(); } + pub fn set_connectors_enabled(&mut self, enabled: bool) { + self.composer.set_connectors_enabled(enabled); + } + #[cfg(target_os = "windows")] pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) { self.composer.set_windows_degraded_sandbox_active(enabled); diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 4736953208..390b44295f 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -14,30 +14,35 @@ use super::selection_popup_common::render_rows_single_line; use crate::key_hint; use crate::render::Insets; use crate::render::RectExt; -use codex_core::skills::model::SkillMetadata; +use crate::text_formatting::truncate_text; +use codex_common::fuzzy_match::fuzzy_match; -use crate::skills_helpers::match_skill; -use crate::skills_helpers::skill_description; -use crate::skills_helpers::skill_display_name; -use crate::skills_helpers::truncated_skill_display_name; +#[derive(Clone, Debug)] +pub(crate) struct MentionItem { + pub(crate) display_name: String, + pub(crate) description: Option, + pub(crate) insert_text: String, + pub(crate) search_terms: Vec, + pub(crate) path: Option, +} pub(crate) struct SkillPopup { query: String, - skills: Vec, + mentions: Vec, state: ScrollState, } impl SkillPopup { - pub(crate) fn new(skills: Vec) -> Self { + pub(crate) fn new(mentions: Vec) -> Self { Self { query: String::new(), - skills, + mentions, state: ScrollState::new(), } } - pub(crate) fn set_skills(&mut self, skills: Vec) { - self.skills = skills; + pub(crate) fn set_mentions(&mut self, mentions: Vec) { + self.mentions = mentions; self.clamp_selection(); } @@ -64,11 +69,11 @@ impl SkillPopup { self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } - pub(crate) fn selected_skill(&self) -> Option<&SkillMetadata> { + pub(crate) fn selected_mention(&self) -> Option<&MentionItem> { let matches = self.filtered_items(); let idx = self.state.selected_idx?; - let skill_idx = matches.get(idx)?; - self.skills.get(*skill_idx) + let mention_idx = matches.get(idx)?; + self.mentions.get(*mention_idx) } fn clamp_selection(&mut self) { @@ -88,14 +93,14 @@ impl SkillPopup { matches .into_iter() .map(|(idx, indices, _score)| { - let skill = &self.skills[idx]; - let name = truncated_skill_display_name(skill); - let description = skill_description(skill).to_string(); + let mention = &self.mentions[idx]; + let name = truncate_text(&mention.display_name, 21); + let description = mention.description.clone().unwrap_or_default(); GenericDisplayRow { name, match_indices: indices, display_shortcut: None, - description: Some(description), + description: Some(description).filter(|desc| !desc.is_empty()), is_disabled: false, disabled_reason: None, wrap_indent: None, @@ -109,23 +114,48 @@ impl SkillPopup { let mut out: Vec<(usize, Option>, i32)> = Vec::new(); if filter.is_empty() { - for (idx, _skill) in self.skills.iter().enumerate() { + for (idx, _mention) in self.mentions.iter().enumerate() { out.push((idx, None, 0)); } return out; } - for (idx, skill) in self.skills.iter().enumerate() { - let display_name = skill_display_name(skill); - if let Some((indices, score)) = match_skill(filter, display_name, &skill.name) { + for (idx, mention) in self.mentions.iter().enumerate() { + let mut best_match: Option<(Option>, i32)> = None; + + if let Some((indices, score)) = fuzzy_match(&mention.display_name, filter) { + best_match = Some((Some(indices), score)); + } + + for term in &mention.search_terms { + if term == &mention.display_name { + continue; + } + + if let Some((_indices, score)) = fuzzy_match(term, filter) { + match best_match.as_mut() { + Some((best_indices, best_score)) => { + if score > *best_score { + *best_score = score; + *best_indices = None; + } + } + None => { + best_match = Some((None, score)); + } + } + } + } + + if let Some((indices, score)) = best_match { out.push((idx, indices, score)); } } out.sort_by(|a, b| { a.2.cmp(&b.2).then_with(|| { - let an = skill_display_name(&self.skills[a.0]); - let bn = skill_display_name(&self.skills[b.0]); + let an = self.mentions[a.0].display_name.as_str(); + let bn = self.mentions[b.0].display_name.as_str(); an.cmp(bn) }) }); @@ -154,7 +184,7 @@ impl WidgetRef for SkillPopup { &rows, &self.state, MAX_POPUP_ROWS, - "no skills", + "no matches", ); if let Some(hint_area) = hint_area { let hint_area = Rect { @@ -172,7 +202,7 @@ fn skill_popup_hint_line() -> Line<'static> { Line::from(vec![ "Press ".into(), key_hint::plain(KeyCode::Enter).into(), - " to select or ".into(), + " to insert or ".into(), key_hint::plain(KeyCode::Esc).into(), " to close".into(), ]) diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index e0707a4228..1efab04ecd 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -11,6 +11,7 @@ use crate::slash_command::built_in_slash_commands; /// Return the built-ins that should be visible/usable for the current input. pub(crate) fn builtins_for_input( collaboration_modes_enabled: bool, + connectors_enabled: bool, personality_command_enabled: bool, allow_elevate_sandbox: bool, ) -> Vec<(&'static str, SlashCommand)> { @@ -18,6 +19,7 @@ pub(crate) fn builtins_for_input( .into_iter() .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) .filter(|(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) + .filter(|(_, cmd)| connectors_enabled || *cmd != SlashCommand::Apps) .filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality) .collect() } @@ -26,11 +28,13 @@ pub(crate) fn builtins_for_input( pub(crate) fn find_builtin_command( name: &str, collaboration_modes_enabled: bool, + connectors_enabled: bool, personality_command_enabled: bool, allow_elevate_sandbox: bool, ) -> Option { builtins_for_input( collaboration_modes_enabled, + connectors_enabled, personality_command_enabled, allow_elevate_sandbox, ) @@ -43,11 +47,13 @@ pub(crate) fn find_builtin_command( pub(crate) fn has_builtin_prefix( name: &str, collaboration_modes_enabled: bool, + connectors_enabled: bool, personality_command_enabled: bool, allow_elevate_sandbox: bool, ) -> bool { builtins_for_input( collaboration_modes_enabled, + connectors_enabled, personality_command_enabled, allow_elevate_sandbox, ) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dcbb4d7c22..8a0dd81afe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -31,6 +31,7 @@ use std::time::Instant; use crate::version::CODEX_CLI_VERSION; use codex_app_server_protocol::AuthMode; use codex_backend_client::Client as BackendClient; +use codex_chatgpt::connectors; use codex_core::config::Config; use codex_core::config::ConstraintResult; use codex_core::config::types::Notifications; @@ -131,6 +132,7 @@ const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; use crate::app_event::AppEvent; +use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; @@ -189,7 +191,9 @@ pub(crate) use self::agent::spawn_op_forwarder; mod session_header; use self::session_header::SessionHeader; mod skills; -use self::skills::find_skill_mentions; +use self::skills::collect_tool_mentions; +use self::skills::find_app_mentions; +use self::skills::find_skill_mentions_with_tool_mentions; use crate::streaming::controller::StreamController; use std::path::Path; @@ -386,6 +390,15 @@ enum RateLimitSwitchPromptState { Shown, } +#[derive(Debug, Clone, Default)] +enum ConnectorsCacheState { + #[default] + Uninitialized, + Loading, + Ready(ConnectorsSnapshot), + Failed(String), +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExternalEditorState { #[default] @@ -460,6 +473,7 @@ pub(crate) struct ChatWidget { /// bottom pane is treated as "running" while this is populated, even if no agent turn is /// currently executing. mcp_startup_status: Option>, + connectors_cache: ConnectorsCacheState, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -549,6 +563,7 @@ pub(crate) struct UserMessage { text: String, local_images: Vec, text_elements: Vec, + mention_paths: HashMap, } impl From for UserMessage { @@ -558,6 +573,7 @@ impl From for UserMessage { local_images: Vec::new(), // Plain text conversion has no UI element ranges. text_elements: Vec::new(), + mention_paths: HashMap::new(), } } } @@ -569,6 +585,7 @@ impl From<&str> for UserMessage { local_images: Vec::new(), // Plain text conversion has no UI element ranges. text_elements: Vec::new(), + mention_paths: HashMap::new(), } } } @@ -594,6 +611,7 @@ pub(crate) fn create_initial_user_message( text, local_images, text_elements, + mention_paths: HashMap::new(), }) } } @@ -607,12 +625,14 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) text, text_elements, local_images, + mention_paths, } = message; if local_images.is_empty() { return UserMessage { text, text_elements, local_images, + mention_paths, }; } @@ -667,6 +687,7 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) text: rebuilt, local_images: remapped_images, text_elements: rebuilt_elements, + mention_paths, } } @@ -732,6 +753,7 @@ impl ChatWidget { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); + self.bottom_pane.set_connectors_snapshot(None); self.thread_id = Some(event.session_id); self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); @@ -762,6 +784,9 @@ 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); } @@ -797,6 +822,25 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn open_app_link_view( + &mut self, + title: String, + description: Option, + instructions: String, + url: String, + is_installed: bool, + ) { + let view = crate::bottom_pane::AppLinkView::new( + title, + description, + instructions, + url, + is_installed, + ); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { let params = crate::bottom_pane::feedback_upload_consent_params( self.app_event_tx.clone(), @@ -1240,6 +1284,7 @@ impl ChatWidget { text: self.bottom_pane.composer_text(), text_elements: self.bottom_pane.composer_text_elements(), local_images: self.bottom_pane.composer_local_images(), + mention_paths: HashMap::new(), }; let mut to_merge: Vec = self.queued_user_messages.drain(..).collect(); @@ -1251,6 +1296,7 @@ impl ChatWidget { text: String::new(), text_elements: Vec::new(), local_images: Vec::new(), + mention_paths: HashMap::new(), }; let mut combined_offset = 0usize; let mut next_image_label = 1usize; @@ -1272,6 +1318,7 @@ impl ChatWidget { elem })); combined.local_images.extend(message.local_images); + combined.mention_paths.extend(message.mention_paths); } Some(combined) @@ -2028,6 +2075,7 @@ impl ChatWidget { unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, + connectors_cache: ConnectorsCacheState::default(), interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -2071,6 +2119,10 @@ impl ChatWidget { ); widget.update_collaboration_mode_indicator(); + widget + .bottom_pane + .set_connectors_enabled(widget.config.features.enabled(Feature::Apps)); + widget } @@ -2161,6 +2213,7 @@ impl ChatWidget { unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, + connectors_cache: ConnectorsCacheState::default(), interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -2287,6 +2340,7 @@ impl ChatWidget { unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, + connectors_cache: ConnectorsCacheState::default(), interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -2436,6 +2490,7 @@ impl ChatWidget { .bottom_pane .take_recent_submission_images_with_placeholders(), text_elements, + mention_paths: self.bottom_pane.take_mention_paths(), }; if self.is_session_configured() { // Submitted is only emitted when steer is enabled (Enter sends immediately). @@ -2458,6 +2513,7 @@ impl ChatWidget { .bottom_pane .take_recent_submission_images_with_placeholders(), text_elements, + mention_paths: self.bottom_pane.take_mention_paths(), }; self.queue_user_message(user_message); } @@ -2675,6 +2731,9 @@ impl ChatWidget { SlashCommand::Mcp => { self.add_mcp_output(); } + SlashCommand::Apps => { + self.add_connectors_output(); + } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { self.add_info_message( @@ -2833,6 +2892,7 @@ impl ChatWidget { text, local_images, text_elements, + mention_paths, } = user_message; if text.is_empty() && local_images.is_empty() { return; @@ -2871,8 +2931,15 @@ impl ChatWidget { }); } + let mentions = collect_tool_mentions(&text, &mention_paths); + let mut skill_names_lower: HashSet = HashSet::new(); + if let Some(skills) = self.bottom_pane.skills() { - let skill_mentions = find_skill_mentions(&text, skills); + skill_names_lower = skills + .iter() + .map(|skill| skill.name.to_ascii_lowercase()) + .collect(); + let skill_mentions = find_skill_mentions_with_tool_mentions(&mentions, skills); for skill in skill_mentions { items.push(UserInput::Skill { name: skill.name.clone(), @@ -2881,6 +2948,17 @@ impl ChatWidget { } } + if let Some(apps) = self.connectors_for_mentions() { + let app_mentions = find_app_mentions(&mentions, apps, &skill_names_lower); + for app in app_mentions { + let app_id = app.id.as_str(); + items.push(UserInput::Mention { + name: app.name.clone(), + path: format!("app://{app_id}"), + }); + } + } + let effective_mode = self.effective_collaboration_mode(); let collaboration_mode = if self.collaboration_modes_enabled() { self.active_collaboration_mask @@ -3289,6 +3367,28 @@ impl ChatWidget { } } + fn prefetch_connectors(&mut self) { + if !self.connectors_enabled() { + return; + } + if matches!(self.connectors_cache, ConnectorsCacheState::Loading) { + return; + } + + self.connectors_cache = ConnectorsCacheState::Loading; + let config = self.config.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result: Result = async { + let connectors = connectors::list_connectors(&config).await?; + Ok(ConnectorsSnapshot { connectors }) + } + .await; + let result = result.map_err(|err| format!("Failed to load apps: {err}")); + app_event_tx.send(AppEvent::ConnectorsLoaded(result)); + }); + } + fn prefetch_rate_limits(&mut self) { self.stop_rate_limit_poller(); @@ -4973,6 +5073,21 @@ impl ChatWidget { self.request_redraw(); } + fn connectors_enabled(&self) -> bool { + self.config.features.enabled(Feature::Apps) + } + + fn connectors_for_mentions(&self) -> Option<&[connectors::AppInfo]> { + if !self.connectors_enabled() { + return None; + } + + match &self.connectors_cache { + ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()), + _ => None, + } + } + /// Build a placeholder header cell while the session is configuring. fn placeholder_session_header_cell(config: &Config) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); @@ -5036,6 +5151,152 @@ impl ChatWidget { } } + pub(crate) fn add_connectors_output(&mut self) { + if !self.connectors_enabled() { + self.add_info_message( + "Apps are disabled.".to_string(), + Some("Enable the apps feature to use $ or /apps.".to_string()), + ); + return; + } + + match self.connectors_cache.clone() { + ConnectorsCacheState::Ready(snapshot) => { + if snapshot.connectors.is_empty() { + self.add_info_message("No apps available.".to_string(), None); + } else { + self.open_connectors_popup(&snapshot.connectors); + } + } + ConnectorsCacheState::Failed(err) => { + self.add_to_history(history_cell::new_error_event(err)); + // Retry on demand so `/apps` can recover after transient failures. + self.prefetch_connectors(); + } + ConnectorsCacheState::Loading => { + self.add_to_history(history_cell::new_info_event( + "Apps are still loading.".to_string(), + Some("Try again in a moment.".to_string()), + )); + } + ConnectorsCacheState::Uninitialized => { + self.prefetch_connectors(); + self.add_to_history(history_cell::new_info_event( + "Apps are still loading.".to_string(), + Some("Try again in a moment.".to_string()), + )); + } + } + self.request_redraw(); + } + + fn open_connectors_popup(&mut self, connectors: &[connectors::AppInfo]) { + let total = connectors.len(); + let installed = connectors + .iter() + .filter(|connector| connector.is_accessible) + .count(); + let mut header = ColumnRenderable::new(); + header.push(Line::from("Apps".bold())); + header.push(Line::from( + "Use $ to insert an installed app into your prompt.".dim(), + )); + header.push(Line::from( + format!("Installed {installed} of {total} available apps.").dim(), + )); + let mut items: Vec = Vec::with_capacity(connectors.len()); + for connector in connectors { + let connector_label = connectors::connector_display_label(connector); + let connector_title = connector_label.clone(); + let link_description = Self::connector_description(connector); + let description = Self::connector_brief_description(connector); + let search_value = format!("{connector_label} {}", connector.id); + let mut item = SelectionItem { + name: connector_label, + description: Some(description), + search_value: Some(search_value), + ..Default::default() + }; + let is_installed = connector.is_accessible; + let (selected_label, missing_label, instructions) = if connector.is_accessible { + ( + "Press Enter to view the app link.", + "App link unavailable.", + "Manage this app in your browser.", + ) + } else { + ( + "Press Enter to view the install link.", + "Install link unavailable.", + "Install this app in your browser, then reload Codex.", + ) + }; + if let Some(install_url) = connector.install_url.clone() { + let title = connector_title.clone(); + let instructions = instructions.to_string(); + let description = link_description.clone(); + item.actions = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenAppLink { + title: title.clone(), + description: description.clone(), + instructions: instructions.clone(), + url: install_url.clone(), + is_installed, + }); + })]; + item.dismiss_on_select = true; + item.selected_description = Some(selected_label.to_string()); + } else { + item.actions = vec![Box::new(move |tx| { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(missing_label.to_string(), None), + ))); + })]; + item.dismiss_on_select = true; + item.selected_description = Some(missing_label.to_string()); + } + items.push(item); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(Self::connectors_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search apps".to_string()), + ..Default::default() + }); + } + + fn connectors_popup_hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to close.".into(), + ]) + } + + fn connector_brief_description(connector: &connectors::AppInfo) -> String { + let status_label = if connector.is_accessible { + "Connected" + } else { + "Can be installed" + }; + match Self::connector_description(connector) { + Some(description) => format!("{status_label} · {description}"), + None => status_label.to_string(), + } + } + + fn connector_description(connector: &connectors::AppInfo) -> Option { + connector + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) + } + /// Forward file-search results to the bottom pane. pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { self.bottom_pane.on_file_search_result(query, matches); @@ -5220,6 +5481,19 @@ impl ChatWidget { self.set_skills_from_response(&ev); } + pub(crate) fn on_connectors_loaded(&mut self, result: Result) { + self.connectors_cache = match result { + Ok(connectors) => ConnectorsCacheState::Ready(connectors), + Err(err) => ConnectorsCacheState::Failed(err), + }; + if let ConnectorsCacheState::Ready(snapshot) = &self.connectors_cache { + self.bottom_pane + .set_connectors_snapshot(Some(snapshot.clone())); + } else { + self.bottom_pane.set_connectors_snapshot(None); + } + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index d6893a9e3c..0920c8837b 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -12,6 +12,8 @@ use crate::bottom_pane::SkillsToggleView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::skills_helpers::skill_description; use crate::skills_helpers::skill_display_name; +use codex_chatgpt::connectors::AppInfo; +use codex_core::connectors::connector_mention_slug; use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::SkillMetadata as ProtocolSkillMetadata; use codex_core::protocol::SkillsListEntry; @@ -192,22 +194,256 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata { } } -pub(crate) fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec { - let mut seen: HashSet = HashSet::new(); - let mut matches: Vec = Vec::new(); - for skill in skills { - if seen.contains(&skill.name) { - continue; - } - let needle = format!("${}", skill.name); - if text.contains(&needle) { - seen.insert(skill.name.clone()); - matches.push(skill.clone()); - } - } - matches -} - fn normalize_skill_config_path(path: &Path) -> PathBuf { dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } + +pub(crate) fn collect_tool_mentions( + text: &str, + mention_paths: &HashMap, +) -> ToolMentions { + let mut mentions = extract_tool_mentions_from_text(text); + for (name, path) in mention_paths { + if mentions.names.contains(name) { + mentions.linked_paths.insert(name.clone(), path.clone()); + } + } + mentions +} + +pub(crate) fn find_skill_mentions_with_tool_mentions( + mentions: &ToolMentions, + skills: &[SkillMetadata], +) -> Vec { + let mention_skill_paths: HashSet<&str> = mentions + .linked_paths + .values() + .filter(|path| is_skill_path(path)) + .map(|path| normalize_skill_path(path)) + .collect(); + + let mut seen_names = HashSet::new(); + let mut seen_paths = HashSet::new(); + let mut matches: Vec = Vec::new(); + + for skill in skills { + if seen_paths.contains(&skill.path) { + continue; + } + let path_str = skill.path.to_string_lossy(); + if mention_skill_paths.contains(path_str.as_ref()) { + seen_paths.insert(skill.path.clone()); + seen_names.insert(skill.name.clone()); + matches.push(skill.clone()); + } + } + + for skill in skills { + if seen_paths.contains(&skill.path) { + continue; + } + if mentions.names.contains(&skill.name) && seen_names.insert(skill.name.clone()) { + seen_paths.insert(skill.path.clone()); + matches.push(skill.clone()); + } + } + + matches +} + +pub(crate) fn find_app_mentions( + mentions: &ToolMentions, + apps: &[AppInfo], + skill_names_lower: &HashSet, +) -> Vec { + let mut explicit_names = HashSet::new(); + let mut selected_ids = HashSet::new(); + for (name, path) in &mentions.linked_paths { + if let Some(connector_id) = app_id_from_path(path) { + explicit_names.insert(name.clone()); + selected_ids.insert(connector_id.to_string()); + } + } + + let mut slug_counts: HashMap = HashMap::new(); + for app in apps { + let slug = connector_mention_slug(app); + *slug_counts.entry(slug).or_insert(0) += 1; + } + + for app in apps { + let slug = connector_mention_slug(app); + let slug_count = slug_counts.get(&slug).copied().unwrap_or(0); + if mentions.names.contains(&slug) + && !explicit_names.contains(&slug) + && slug_count == 1 + && !skill_names_lower.contains(&slug) + { + selected_ids.insert(app.id.clone()); + } + } + + apps.iter() + .filter(|app| selected_ids.contains(&app.id)) + .cloned() + .collect() +} + +pub(crate) struct ToolMentions { + names: HashSet, + linked_paths: HashMap, +} + +fn extract_tool_mentions_from_text(text: &str) -> ToolMentions { + let text_bytes = text.as_bytes(); + let mut names: HashSet = HashSet::new(); + let mut linked_paths: HashMap = HashMap::new(); + + let mut index = 0; + while index < text_bytes.len() { + let byte = text_bytes[index]; + if byte == b'[' + && let Some((name, path, end_index)) = + parse_linked_tool_mention(text, text_bytes, index) + { + if !is_common_env_var(name) { + if !is_app_or_mcp_path(path) { + names.insert(name.to_string()); + } + linked_paths + .entry(name.to_string()) + .or_insert(path.to_string()); + } + index = end_index; + continue; + } + + if byte != b'$' { + index += 1; + continue; + } + + let name_start = index + 1; + let Some(first_name_byte) = text_bytes.get(name_start) else { + index += 1; + continue; + }; + if !is_mention_name_char(*first_name_byte) { + index += 1; + continue; + } + + let mut name_end = name_start + 1; + while let Some(next_byte) = text_bytes.get(name_end) + && is_mention_name_char(*next_byte) + { + name_end += 1; + } + + let name = &text[name_start..name_end]; + if !is_common_env_var(name) { + names.insert(name.to_string()); + } + index = name_end; + } + + ToolMentions { + names, + linked_paths, + } +} + +fn parse_linked_tool_mention<'a>( + text: &'a str, + text_bytes: &[u8], + start: usize, +) -> Option<(&'a str, &'a str, usize)> { + let dollar_index = start + 1; + if text_bytes.get(dollar_index) != Some(&b'$') { + return None; + } + + let name_start = dollar_index + 1; + let first_name_byte = text_bytes.get(name_start)?; + if !is_mention_name_char(*first_name_byte) { + return None; + } + + let mut name_end = name_start + 1; + while let Some(next_byte) = text_bytes.get(name_end) + && is_mention_name_char(*next_byte) + { + name_end += 1; + } + + if text_bytes.get(name_end) != Some(&b']') { + return None; + } + + let mut path_start = name_end + 1; + while let Some(next_byte) = text_bytes.get(path_start) + && next_byte.is_ascii_whitespace() + { + path_start += 1; + } + if text_bytes.get(path_start) != Some(&b'(') { + return None; + } + + let mut path_end = path_start + 1; + while let Some(next_byte) = text_bytes.get(path_end) + && *next_byte != b')' + { + path_end += 1; + } + if text_bytes.get(path_end) != Some(&b')') { + return None; + } + + let path = text[path_start + 1..path_end].trim(); + if path.is_empty() { + return None; + } + + let name = &text[name_start..name_end]; + Some((name, path, path_end + 1)) +} + +fn is_common_env_var(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + matches!( + upper.as_str(), + "PATH" + | "HOME" + | "USER" + | "SHELL" + | "PWD" + | "TMPDIR" + | "TEMP" + | "TMP" + | "LANG" + | "TERM" + | "XDG_CONFIG_HOME" + ) +} + +fn is_mention_name_char(byte: u8) -> bool { + matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') +} + +fn is_skill_path(path: &str) -> bool { + !is_app_or_mcp_path(path) +} + +fn normalize_skill_path(path: &str) -> &str { + path.strip_prefix("skill://").unwrap_or(path) +} + +fn app_id_from_path(path: &str) -> Option<&str> { + path.strip_prefix("app://") + .filter(|value| !value.is_empty()) +} + +fn is_app_or_mcp_path(path: &str) -> bool { + path.starts_with("app://") || path.starts_with("mcp://") +} diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 78fbc15b49..570a72750b 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -355,6 +355,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { path: first_images[0].clone(), }], text_elements: first_elements, + mention_paths: HashMap::new(), }); chat.queued_user_messages.push_back(UserMessage { text: second_text, @@ -363,6 +364,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { path: second_images[0].clone(), }], text_elements: second_elements, + mention_paths: HashMap::new(), }); chat.refresh_queued_user_messages(); @@ -442,6 +444,7 @@ async fn remap_placeholders_uses_attachment_labels() { text, text_elements: elements, local_images: attachments, + mention_paths: HashMap::new(), }; let mut next_label = 3usize; let remapped = remap_placeholders_for_message(message, &mut next_label); @@ -502,6 +505,7 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { text, text_elements: elements, local_images: attachments, + mention_paths: HashMap::new(), }; let mut next_label = 3usize; let remapped = remap_placeholders_for_message(message, &mut next_label); @@ -810,6 +814,7 @@ async fn make_chatwidget_manual( unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, + connectors_cache: ConnectorsCacheState::default(), interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), diff --git a/codex-rs/tui/src/skills_helpers.rs b/codex-rs/tui/src/skills_helpers.rs index 8332be886f..1a8c78bb10 100644 --- a/codex-rs/tui/src/skills_helpers.rs +++ b/codex-rs/tui/src/skills_helpers.rs @@ -26,10 +26,6 @@ pub(crate) fn truncate_skill_name(name: &str) -> String { truncate_text(name, SKILL_NAME_TRUNCATE_LEN) } -pub(crate) fn truncated_skill_display_name(skill: &SkillMetadata) -> String { - truncate_skill_name(skill_display_name(skill)) -} - pub(crate) fn match_skill( filter: &str, display_name: &str, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 8a7a320665..5d1aa8dc45 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -33,6 +33,7 @@ pub enum SlashCommand { Mention, Status, Mcp, + Apps, Logout, Quit, Exit, @@ -69,6 +70,7 @@ impl SlashCommand { SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::Experimental => "toggle experimental features", SlashCommand::Mcp => "list configured MCP tools", + SlashCommand::Apps => "manage apps", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", SlashCommand::TestApproval => "test approval request", @@ -104,6 +106,7 @@ impl SlashCommand { | SlashCommand::Status | SlashCommand::Ps | SlashCommand::Mcp + | SlashCommand::Apps | SlashCommand::Feedback | SlashCommand::Quit | SlashCommand::Exit => true, From 335713f7e98551ee7e8c84d7afb380dddf075ead Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 28 Jan 2026 22:00:48 -0700 Subject: [PATCH 10/70] chore(core) personality under development (#10133) ## Summary Have one or two more changes coming in for this. --- codex-rs/core/src/features.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index c7cd411145..9bc52537b7 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -552,11 +552,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Personality, key: "personality", - stage: Stage::Experimental { - name: "Personality", - menu_description: "Choose a communication style for Codex.", - announcement: "NEW! Update codex's communication style with /personality. Enable in /experimental!", - }, + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { From 8cc338aecf73ee1a04917413ee2d9905c468fb4b Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 28 Jan 2026 21:51:51 -0800 Subject: [PATCH 11/70] emit a metric when we can't spawn powershell (#10125) This will help diagnose and measure the impact of a user-reported bug with the elevated sandbox and powershell --- codex-rs/core/src/exec.rs | 75 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 37f6c2de96..e0d65c8305 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -232,6 +232,72 @@ pub(crate) async fn execute_exec_env( finalize_exec_result(raw_output_result, sandbox, duration) } +#[cfg(target_os = "windows")] +fn extract_create_process_as_user_error_code(err: &str) -> Option { + let marker = "CreateProcessAsUserW failed: "; + let start = err.find(marker)? + marker.len(); + let tail = &err[start..]; + let digits: String = tail.chars().take_while(char::is_ascii_digit).collect(); + if digits.is_empty() { + None + } else { + Some(digits) + } +} + +#[cfg(target_os = "windows")] +fn windowsapps_path_kind(path: &str) -> &'static str { + let lower = path.to_ascii_lowercase(); + if lower.contains("\\program files\\windowsapps\\") { + return "windowsapps_package"; + } + if lower.contains("\\appdata\\local\\microsoft\\windowsapps\\") { + return "windowsapps_alias"; + } + if lower.contains("\\windowsapps\\") { + return "windowsapps_other"; + } + "other" +} + +#[cfg(target_os = "windows")] +fn record_windows_sandbox_spawn_failure( + command_path: Option<&str>, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, + err: &str, +) { + let Some(error_code) = extract_create_process_as_user_error_code(err) else { + return; + }; + let path = command_path.unwrap_or("unknown"); + let exe = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown") + .to_ascii_lowercase(); + let path_kind = windowsapps_path_kind(path); + let level = if matches!( + windows_sandbox_level, + codex_protocol::config_types::WindowsSandboxLevel::Elevated + ) { + "elevated" + } else { + "legacy" + }; + if let Some(metrics) = codex_otel::metrics::global() { + let _ = metrics.counter( + "codex.windows_sandbox.createprocessasuserw_failed", + 1, + &[ + ("error_code", error_code.as_str()), + ("path_kind", path_kind), + ("exe", exe.as_str()), + ("level", level), + ], + ); + } +} + #[cfg(target_os = "windows")] async fn exec_windows_sandbox( params: ExecParams, @@ -265,7 +331,9 @@ async fn exec_windows_sandbox( "windows sandbox: failed to resolve codex_home: {err}" ))) })?; - let use_elevated = matches!(windows_sandbox_level, WindowsSandboxLevel::Elevated); + let command_path = command.first().cloned(); + let sandbox_level = windows_sandbox_level; + let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated); let spawn_res = tokio::task::spawn_blocking(move || { if use_elevated { run_windows_sandbox_capture_elevated( @@ -294,6 +362,11 @@ async fn exec_windows_sandbox( let capture = match spawn_res { Ok(Ok(v)) => v, Ok(Err(err)) => { + record_windows_sandbox_spawn_failure( + command_path.as_deref(), + sandbox_level, + &err.to_string(), + ); return Err(CodexErr::Io(io::Error::other(format!( "windows sandbox: {err}" )))); From be33de3f8768a47f426761fa6cc017aef9716135 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 28 Jan 2026 22:51:57 -0700 Subject: [PATCH 12/70] fix(tui) reorder personality command (#10134) ## Summary Reorder it down the list ## Testing - [x] Tests pass --- codex-rs/tui/src/slash_command.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 5d1aa8dc45..9bab314610 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -13,7 +13,6 @@ pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. Model, - Personality, Approvals, Permissions, #[strum(serialize = "setup-elevated-sandbox")] @@ -40,6 +39,7 @@ pub enum SlashCommand { Feedback, Rollout, Ps, + Personality, TestApproval, } From b4b47630098b6d1ae89f6b2873dd313859b38ec5 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 28 Jan 2026 22:58:55 -0700 Subject: [PATCH 13/70] fix(ci) missing package.json for shell-mcp-tool (#10135) ## Summary This _should_ be the final place to fix. --- .github/workflows/shell-tool-mcp.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 446af67918..39f176cbf4 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -445,6 +445,9 @@ jobs: id-token: write contents: read steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Setup pnpm uses: pnpm/action-setup@v4 with: From e70592f85ad2c86aaa5e45754d9e2dc89a8ab113 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Wed, 28 Jan 2026 22:13:53 -0800 Subject: [PATCH 14/70] fix: ignore key release events during onboarding (#10131) ## Summary - guard onboarding key handling to ignore KeyEventKind::Release - handle key events at the onboarding screen boundary to avoid double-triggering widgets ## Related - https://github.com/ratatui/ratatui/issues/347 ## Testing - cd codex-rs && just fmt - cd codex-rs && cargo test -p codex-tui --- codex-rs/tui/src/onboarding/onboarding_screen.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 78a7f0ce85..09be507fff 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -211,6 +211,9 @@ impl OnboardingScreen { impl KeyboardHandler for OnboardingScreen { fn handle_key_event(&mut self, key_event: KeyEvent) { + if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return; + } let is_api_key_entry_active = self.is_api_key_entry_active(); let should_quit = match key_event { KeyEvent { From 4d9ae3a2989949d79b75538ab5769c35547f084c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 28 Jan 2026 23:31:25 -0800 Subject: [PATCH 15/70] fix: remove references to corepack (#10138) Currently, our `npm publish` logic is failing. There were a number of things that were merged recently that seemed to contribute to this situation, though I think we have fixed most of them, but this one stands out: https://github.com/openai/codex/pull/10115 As best I can tell, we tried to fix the pnpm version to a specific hash, but we did not do it consistently (though `shell-tool-mcp/package.json` had it specified twice...), so for this PR, I ran: ``` $ git ls-files | grep package.json codex-cli/package.json codex-rs/responses-api-proxy/npm/package.json package.json sdk/typescript/package.json shell-tool-mcp/package.json ``` and ensured that all of them now have this line: ```json "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" ``` I also went and deleted all of the `corepack` stuff that was added by https://github.com/openai/codex/pull/10115. If someone can explain why we need it and verify it does not break `npm publish`, then we can bring it back. --- .github/workflows/shell-tool-mcp-ci.yml | 9 --- .github/workflows/shell-tool-mcp.yml | 18 ----- PNPM.md | 70 ------------------- codex-cli/package.json | 3 +- codex-rs/responses-api-proxy/npm/package.json | 3 +- sdk/typescript/package.json | 3 +- shell-tool-mcp/package.json | 1 - 7 files changed, 6 insertions(+), 101 deletions(-) delete mode 100644 PNPM.md diff --git a/.github/workflows/shell-tool-mcp-ci.yml b/.github/workflows/shell-tool-mcp-ci.yml index bea88ad891..739cc6e409 100644 --- a/.github/workflows/shell-tool-mcp-ci.yml +++ b/.github/workflows/shell-tool-mcp-ci.yml @@ -35,15 +35,6 @@ jobs: node-version: ${{ env.NODE_VERSION }} cache: "pnpm" - - name: Enable Corepack - run: corepack enable - - - name: Activate pnpm from package.json - run: corepack prepare --activate - - - name: Verify pnpm version - run: pnpm --version - - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 39f176cbf4..d6c85d9ddc 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -351,15 +351,6 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} - - name: Enable Corepack - run: corepack enable - - - name: Activate pnpm from package.json - run: corepack prepare --activate - - - name: Verify pnpm version - run: pnpm --version - - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile @@ -460,15 +451,6 @@ jobs: registry-url: https://registry.npmjs.org scope: "@openai" - - name: Enable Corepack - run: corepack enable - - - name: Activate pnpm from package.json - run: corepack prepare --activate - - - name: Verify pnpm version - run: pnpm --version - - name: Download npm tarball uses: actions/download-artifact@v7 with: diff --git a/PNPM.md b/PNPM.md deleted file mode 100644 index 9f05e39cf8..0000000000 --- a/PNPM.md +++ /dev/null @@ -1,70 +0,0 @@ -# Migration to pnpm - -This project has been migrated from npm to pnpm to improve dependency management and developer experience. - -## Why pnpm? - -- **Faster installation**: pnpm is significantly faster than npm and yarn -- **Disk space savings**: pnpm uses a content-addressable store to avoid duplication -- **Phantom dependency prevention**: pnpm creates a strict node_modules structure -- **Native workspaces support**: simplified monorepo management - -## How to use pnpm - -### Installation - -```bash -# Global installation of pnpm -npm install -g pnpm@10.28.2 - -# Or with corepack (available with Node.js 22+) -corepack enable -corepack prepare pnpm@10.8.1 --activate -``` - -### Common commands - -| npm command | pnpm equivalent | -| --------------- | ---------------- | -| `npm install` | `pnpm install` | -| `npm run build` | `pnpm run build` | -| `npm test` | `pnpm test` | -| `npm run lint` | `pnpm run lint` | - -### Workspace-specific commands - -| Action | Command | -| ------------------------------------------ | ---------------------------------------- | -| Run a command in a specific package | `pnpm --filter @openai/codex run build` | -| Install a dependency in a specific package | `pnpm --filter @openai/codex add lodash` | -| Run a command in all packages | `pnpm -r run test` | - -## Monorepo structure - -``` -codex/ -├── pnpm-workspace.yaml # Workspace configuration -├── .npmrc # pnpm configuration -├── package.json # Root dependencies and scripts -├── codex-cli/ # Main package -│ └── package.json # codex-cli specific dependencies -└── docs/ # Documentation (future package) -``` - -## Configuration files - -- **pnpm-workspace.yaml**: Defines the packages included in the monorepo -- **.npmrc**: Configures pnpm behavior -- **Root package.json**: Contains shared scripts and dependencies - -## CI/CD - -CI/CD workflows have been updated to use pnpm instead of npm. Make sure your CI environments use pnpm 10.28.2 or higher. - -## Known issues - -If you encounter issues with pnpm, try the following solutions: - -1. Remove the `node_modules` folder and `pnpm-lock.yaml` file, then run `pnpm install` -2. Make sure you're using pnpm 10.28.2 or higher -3. Verify that Node.js 22 or higher is installed diff --git a/codex-cli/package.json b/codex-cli/package.json index b83309e42b..3d1a2dcc2c 100644 --- a/codex-cli/package.json +++ b/codex-cli/package.json @@ -17,5 +17,6 @@ "type": "git", "url": "git+https://github.com/openai/codex.git", "directory": "codex-cli" - } + }, + "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" } diff --git a/codex-rs/responses-api-proxy/npm/package.json b/codex-rs/responses-api-proxy/npm/package.json index f3956a77d6..d72b60e218 100644 --- a/codex-rs/responses-api-proxy/npm/package.json +++ b/codex-rs/responses-api-proxy/npm/package.json @@ -17,5 +17,6 @@ "type": "git", "url": "git+https://github.com/openai/codex.git", "directory": "codex-rs/responses-api-proxy/npm" - } + }, + "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" } diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 55ecd1abf3..ab29c3fcec 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -62,5 +62,6 @@ "typescript-eslint": "^8.45.0", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.6" - } + }, + "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" } diff --git a/shell-tool-mcp/package.json b/shell-tool-mcp/package.json index c6dfcd7a55..24798b2f6e 100644 --- a/shell-tool-mcp/package.json +++ b/shell-tool-mcp/package.json @@ -3,7 +3,6 @@ "version": "0.0.0-dev", "description": "Codex MCP server for the shell tool with patched Bash and exec wrappers.", "license": "Apache-2.0", - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", "bin": { "codex-shell-tool-mcp": "bin/mcp-server.js" }, From 780482da84e2bb14b9900e75b04ca2e4f65dfddb Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 29 Jan 2026 10:23:03 +0100 Subject: [PATCH 16/70] feat: add log db (#10086) Add a log DB. The goal is just to store our logs in a `.sqlite` DB to make it easier to crawl them and drop the oldest ones. --- codex-rs/Cargo.lock | 2 + codex-rs/core/src/state_db.rs | 1 + codex-rs/state/Cargo.toml | 1 + codex-rs/state/migrations/0002_logs.sql | 14 ++ codex-rs/state/src/lib.rs | 2 + codex-rs/state/src/log_db.rs | 162 ++++++++++++++++++ codex-rs/state/src/model/log.rs | 14 ++ codex-rs/state/src/model/mod.rs | 15 ++ .../{model.rs => model/thread_metadata.rs} | 0 codex-rs/state/src/runtime.rs | 30 ++++ codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/lib.rs | 6 + 12 files changed, 248 insertions(+) create mode 100644 codex-rs/state/migrations/0002_logs.sql create mode 100644 codex-rs/state/src/log_db.rs create mode 100644 codex-rs/state/src/model/log.rs create mode 100644 codex-rs/state/src/model/mod.rs rename codex-rs/state/src/{model.rs => model/thread_metadata.rs} (100%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 08cc850524..0ae6c60d42 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1859,6 +1859,7 @@ dependencies = [ "sqlx", "tokio", "tracing", + "tracing-subscriber", "uuid", ] @@ -1897,6 +1898,7 @@ dependencies = [ "codex-login", "codex-otel", "codex-protocol", + "codex-state", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-pty", diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/core/src/state_db.rs index d859328e1c..74c9ed7383 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/core/src/state_db.rs @@ -12,6 +12,7 @@ use codex_protocol::ThreadId; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_state::DB_METRIC_BACKFILL; +pub use codex_state::LogEntry; use codex_state::STATE_DB_FILENAME; use codex_state::ThreadMetadataBuilder; use serde_json::Value; diff --git a/codex-rs/state/Cargo.toml b/codex-rs/state/Cargo.toml index 810e250ca7..b8c7fce0e3 100644 --- a/codex-rs/state/Cargo.toml +++ b/codex-rs/state/Cargo.toml @@ -14,6 +14,7 @@ serde_json = { workspace = true } sqlx = { workspace = true } tokio = { workspace = true, features = ["fs", "io-util", "macros", "rt-multi-thread", "sync", "time"] } tracing = { workspace = true } +tracing-subscriber = { workspace = true } uuid = { workspace = true } [dev-dependencies] diff --git a/codex-rs/state/migrations/0002_logs.sql b/codex-rs/state/migrations/0002_logs.sql new file mode 100644 index 0000000000..4a193500b9 --- /dev/null +++ b/codex-rs/state/migrations/0002_logs.sql @@ -0,0 +1,14 @@ +CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + ts_nanos INTEGER NOT NULL, + level TEXT NOT NULL, + target TEXT NOT NULL, + message TEXT, + fields_json TEXT NOT NULL, + module_path TEXT, + file TEXT, + line INTEGER +); + +CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC); diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 67533a3791..91ebacea71 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -5,11 +5,13 @@ //! orchestration and rollout scanning live in `codex-core`. mod extract; +pub mod log_db; mod migrations; mod model; mod paths; mod runtime; +pub use model::LogEntry; /// Preferred entrypoint: owns configuration and metrics. pub use runtime::StateRuntime; diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs new file mode 100644 index 0000000000..c03b7977d3 --- /dev/null +++ b/codex-rs/state/src/log_db.rs @@ -0,0 +1,162 @@ +//! Tracing log export into the state SQLite database. +//! +//! This module provides a `tracing_subscriber::Layer` that captures events and +//! inserts them into the `logs` table in `state.sqlite`. The writer runs in a +//! background task and batches inserts to keep logging overhead low. +//! +//! ## Usage +//! +//! ```no_run +//! use codex_state::log_db; +//! use tracing_subscriber::prelude::*; +//! +//! # async fn example(state_db: std::sync::Arc) { +//! let layer = log_db::start(state_db); +//! let _ = tracing_subscriber::registry() +//! .with(layer) +//! .try_init(); +//! # } +//! ``` + +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use serde_json::Value; +use tokio::sync::mpsc; +use tracing::Event; +use tracing::field::Field; +use tracing::field::Visit; +use tracing_subscriber::Layer; +use tracing_subscriber::registry::LookupSpan; + +use crate::LogEntry; +use crate::StateRuntime; + +const LOG_QUEUE_CAPACITY: usize = 512; +const LOG_BATCH_SIZE: usize = 64; +const LOG_FLUSH_INTERVAL: Duration = Duration::from_millis(250); + +pub struct LogDbLayer { + sender: mpsc::Sender, +} + +pub fn start(state_db: std::sync::Arc) -> LogDbLayer { + let (sender, receiver) = mpsc::channel(LOG_QUEUE_CAPACITY); + tokio::spawn(run_inserter(state_db, receiver)); + + LogDbLayer { sender } +} + +impl Layer for LogDbLayer +where + S: tracing::Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_event(&self, event: &Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) { + let metadata = event.metadata(); + let mut visitor = JsonVisitor::default(); + event.record(&mut visitor); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)); + let entry = LogEntry { + ts: now.as_secs() as i64, + ts_nanos: now.subsec_nanos() as i64, + level: metadata.level().as_str().to_string(), + target: metadata.target().to_string(), + message: visitor.message, + fields_json: Value::Object(visitor.fields).to_string(), + module_path: metadata.module_path().map(ToString::to_string), + file: metadata.file().map(ToString::to_string), + line: metadata.line().map(|line| line as i64), + }; + + let _ = self.sender.try_send(entry); + } +} + +async fn run_inserter( + state_db: std::sync::Arc, + mut receiver: mpsc::Receiver, +) { + let mut buffer = Vec::with_capacity(LOG_BATCH_SIZE); + let mut ticker = tokio::time::interval(LOG_FLUSH_INTERVAL); + loop { + tokio::select! { + maybe_entry = receiver.recv() => { + match maybe_entry { + Some(entry) => { + buffer.push(entry); + if buffer.len() >= LOG_BATCH_SIZE { + flush(&state_db, &mut buffer).await; + } + } + None => { + flush(&state_db, &mut buffer).await; + break; + } + } + } + _ = ticker.tick() => { + flush(&state_db, &mut buffer).await; + } + } + } +} + +async fn flush(state_db: &std::sync::Arc, buffer: &mut Vec) { + if buffer.is_empty() { + return; + } + let entries = buffer.split_off(0); + let _ = state_db.insert_logs(entries.as_slice()).await; +} + +#[derive(Default)] +struct JsonVisitor { + fields: serde_json::Map, + message: Option, +} + +impl JsonVisitor { + fn record_value(&mut self, field: &Field, value: Value) { + if field.name() == "message" && self.message.is_none() { + self.message = Some(match &value { + Value::String(message) => message.clone(), + _ => value.to_string(), + }); + } + self.fields.insert(field.name().to_string(), value); + } +} + +impl Visit for JsonVisitor { + fn record_i64(&mut self, field: &Field, value: i64) { + self.record_value(field, Value::from(value)); + } + + fn record_u64(&mut self, field: &Field, value: u64) { + self.record_value(field, Value::from(value)); + } + + fn record_bool(&mut self, field: &Field, value: bool) { + self.record_value(field, Value::from(value)); + } + + fn record_f64(&mut self, field: &Field, value: f64) { + self.record_value(field, Value::from(value)); + } + + fn record_str(&mut self, field: &Field, value: &str) { + self.record_value(field, Value::from(value)); + } + + fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) { + self.record_value(field, Value::from(value.to_string())); + } + + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.record_value(field, Value::from(format!("{value:?}"))); + } +} diff --git a/codex-rs/state/src/model/log.rs b/codex-rs/state/src/model/log.rs new file mode 100644 index 0000000000..5662a3725c --- /dev/null +++ b/codex-rs/state/src/model/log.rs @@ -0,0 +1,14 @@ +use serde::Serialize; + +#[derive(Clone, Debug, Serialize)] +pub struct LogEntry { + pub ts: i64, + pub ts_nanos: i64, + pub level: String, + pub target: String, + pub message: Option, + pub fields_json: String, + pub module_path: Option, + pub file: Option, + pub line: Option, +} diff --git a/codex-rs/state/src/model/mod.rs b/codex-rs/state/src/model/mod.rs new file mode 100644 index 0000000000..4921e2357a --- /dev/null +++ b/codex-rs/state/src/model/mod.rs @@ -0,0 +1,15 @@ +mod log; +mod thread_metadata; + +pub use log::LogEntry; +pub use thread_metadata::Anchor; +pub use thread_metadata::BackfillStats; +pub use thread_metadata::ExtractionOutcome; +pub use thread_metadata::SortKey; +pub use thread_metadata::ThreadMetadata; +pub use thread_metadata::ThreadMetadataBuilder; +pub use thread_metadata::ThreadsPage; + +pub(crate) use thread_metadata::ThreadRow; +pub(crate) use thread_metadata::anchor_from_item; +pub(crate) use thread_metadata::datetime_to_epoch_seconds; diff --git a/codex-rs/state/src/model.rs b/codex-rs/state/src/model/thread_metadata.rs similarity index 100% rename from codex-rs/state/src/model.rs rename to codex-rs/state/src/model/thread_metadata.rs diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index f341ee3bb6..34add1347d 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -1,4 +1,5 @@ use crate::DB_ERROR_METRIC; +use crate::LogEntry; use crate::SortKey; use crate::ThreadMetadata; use crate::ThreadMetadataBuilder; @@ -201,6 +202,35 @@ FROM threads }) } + /// Insert one log entry into the logs table. + pub async fn insert_log(&self, entry: &LogEntry) -> anyhow::Result<()> { + self.insert_logs(std::slice::from_ref(entry)).await + } + + /// Insert a batch of log entries into the logs table. + pub async fn insert_logs(&self, entries: &[LogEntry]) -> anyhow::Result<()> { + if entries.is_empty() { + return Ok(()); + } + + let mut builder = QueryBuilder::::new( + "INSERT INTO logs (ts, ts_nanos, level, target, message, fields_json, module_path, file, line) ", + ); + builder.push_values(entries, |mut row, entry| { + row.push_bind(entry.ts) + .push_bind(entry.ts_nanos) + .push_bind(&entry.level) + .push_bind(&entry.target) + .push_bind(&entry.message) + .push_bind(&entry.fields_json) + .push_bind(&entry.module_path) + .push_bind(&entry.file) + .push_bind(entry.line); + }); + builder.build().execute(self.pool.as_ref()).await?; + Ok(()) + } + /// List thread ids using the underlying database (no rollout scanning). pub async fn list_thread_ids( &self, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 820fe20e93..6664e7f9c5 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -42,6 +42,7 @@ codex-file-search = { workspace = true } codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } +codex-state = { workspace = true } codex-utils-absolute-path = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index bced1e0653..a48b806129 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -36,6 +36,7 @@ use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; +use codex_state::log_db; use codex_utils_absolute_path::AbsolutePathBuf; use cwd_prompt::CwdPromptAction; use cwd_prompt::CwdSelection; @@ -351,10 +352,15 @@ pub async fn run_main( let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); + let log_db_layer = codex_core::state_db::init_if_enabled(&config, None) + .await + .map(|db| log_db::start(db).with_filter(env_filter())); + let _ = tracing_subscriber::registry() .with(file_layer) .with(feedback_layer) .with(feedback_metadata_layer) + .with(log_db_layer) .with(otel_logger_layer) .with(otel_tracing_layer) .try_init(); From 714dc8d8bd1a8d28fe71df4e1c1bc5f3f5752b91 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 29 Jan 2026 10:57:50 +0100 Subject: [PATCH 17/70] feat: async backfill (#10089) --- .../app-server/src/codex_message_processor.rs | 6 +- codex-rs/core/src/rollout/metadata.rs | 22 +++++++- codex-rs/core/src/state_db.rs | 55 ++++++++++++------- codex-rs/tui/src/lib.rs | 2 +- 4 files changed, 58 insertions(+), 27 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e86d840b94..e1c982a99d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -168,7 +168,7 @@ use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; use codex_core::rollout_date_parts; use codex_core::sandboxing::SandboxPermissions; -use codex_core::state_db::{self}; +use codex_core::state_db::get_state_db; use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_feedback::CodexFeedback; use codex_login::ServerOptions as LoginServerOptions; @@ -1702,7 +1702,7 @@ impl CodexMessageProcessor { let rollout_path_display = archived_path.display().to_string(); let fallback_provider = self.config.model_provider_id.clone(); - let state_db_ctx = state_db::init_if_enabled(&self.config, None).await; + let state_db_ctx = get_state_db(&self.config, None).await; let archived_folder = self .config .codex_home @@ -3571,7 +3571,7 @@ impl CodexMessageProcessor { } if state_db_ctx.is_none() { - state_db_ctx = state_db::init_if_enabled(&self.config, None).await; + state_db_ctx = get_state_db(&self.config, None).await; } // Move the rollout file to archived. diff --git a/codex-rs/core/src/rollout/metadata.rs b/codex-rs/core/src/rollout/metadata.rs index 32d13ebde2..d08e77e241 100644 --- a/codex-rs/core/src/rollout/metadata.rs +++ b/codex-rs/core/src/rollout/metadata.rs @@ -15,12 +15,14 @@ use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_state::BackfillStats; use codex_state::DB_ERROR_METRIC; +use codex_state::DB_METRIC_BACKFILL; use codex_state::ExtractionOutcome; use codex_state::ThreadMetadataBuilder; use codex_state::apply_rollout_item; use std::cmp::Reverse; use std::path::Path; use std::path::PathBuf; +use tracing::info; use tracing::warn; const ROLLOUT_PREFIX: &str = "rollout-"; @@ -125,7 +127,7 @@ pub(crate) async fn backfill_sessions( runtime: &codex_state::StateRuntime, config: &Config, otel: Option<&OtelManager>, -) -> BackfillStats { +) { let sessions_root = config.codex_home.join(rollout::SESSIONS_SUBDIR); let archived_root = config.codex_home.join(rollout::ARCHIVED_SESSIONS_SUBDIR); let mut rollout_paths: Vec<(PathBuf, bool)> = Vec::new(); @@ -191,7 +193,23 @@ pub(crate) async fn backfill_sessions( } } } - stats + + info!( + "state db backfill scanned={}, upserted={}, failed={}", + stats.scanned, stats.upserted, stats.failed + ); + if let Some(otel) = otel { + otel.counter( + DB_METRIC_BACKFILL, + stats.upserted as i64, + &[("status", "upserted")], + ); + otel.counter( + DB_METRIC_BACKFILL, + stats.failed as i64, + &[("status", "failed")], + ); + } } async fn file_modified_time_utc(path: &Path) -> Option> { diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/core/src/state_db.rs index 74c9ed7383..7990a017ef 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/core/src/state_db.rs @@ -11,7 +11,6 @@ use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; -use codex_state::DB_METRIC_BACKFILL; pub use codex_state::LogEntry; use codex_state::STATE_DB_FILENAME; use codex_state::ThreadMetadataBuilder; @@ -19,15 +18,18 @@ use serde_json::Value; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; -use tracing::info; use tracing::warn; use uuid::Uuid; /// Core-facing handle to the optional SQLite-backed state runtime. pub type StateDbHandle = Arc; -/// Initialize the state runtime when the `sqlite` feature flag is enabled. -pub async fn init_if_enabled(config: &Config, otel: Option<&OtelManager>) -> Option { +/// Initialize the state runtime when the `sqlite` feature flag is enabled. To only be used +/// inside `core`. The initialization should not be done anywhere else. +pub(crate) async fn init_if_enabled( + config: &Config, + otel: Option<&OtelManager>, +) -> Option { let state_path = config.codex_home.join(STATE_DB_FILENAME); if !config.features.enabled(Feature::Sqlite) { // We delete the file on best effort basis to maintain retro-compatibility in the future. @@ -59,27 +61,38 @@ pub async fn init_if_enabled(config: &Config, otel: Option<&OtelManager>) -> Opt } }; if !existed { - let stats = metadata::backfill_sessions(runtime.as_ref(), config, otel).await; - info!( - "state db backfill scanned={}, upserted={}, failed={}", - stats.scanned, stats.upserted, stats.failed - ); - if let Some(otel) = otel { - otel.counter( - DB_METRIC_BACKFILL, - stats.upserted as i64, - &[("status", "upserted")], - ); - otel.counter( - DB_METRIC_BACKFILL, - stats.failed as i64, - &[("status", "failed")], - ); - } + let runtime_for_backfill = Arc::clone(&runtime); + let config_for_backfill = config.clone(); + let otel_for_backfill = otel.cloned(); + tokio::task::spawn(async move { + metadata::backfill_sessions( + runtime_for_backfill.as_ref(), + &config_for_backfill, + otel_for_backfill.as_ref(), + ) + .await; + }); } Some(runtime) } +/// Get the DB if the feature is enabled and the DB exists. +pub async fn get_state_db(config: &Config, otel: Option<&OtelManager>) -> Option { + let state_path = config.codex_home.join(STATE_DB_FILENAME); + if !config.features.enabled(Feature::Sqlite) + || !tokio::fs::try_exists(&state_path).await.unwrap_or(false) + { + return None; + } + codex_state::StateRuntime::init( + config.codex_home.clone(), + config.model_provider_id.clone(), + otel.cloned(), + ) + .await + .ok() +} + /// Open the state runtime when the SQLite file exists, without feature gating. /// /// This is used for parity checks during the SQLite migration phase. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a48b806129..c9e69b7125 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -352,7 +352,7 @@ pub async fn run_main( let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); - let log_db_layer = codex_core::state_db::init_if_enabled(&config, None) + let log_db_layer = codex_core::state_db::get_state_db(&config, None) .await .map(|db| log_db::start(db).with_filter(env_filter())); From 6a06726af2469e766528c5f6460e5f047fabd7f3 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 29 Jan 2026 11:11:47 +0100 Subject: [PATCH 18/70] feat: log db client (#10087) ``` just log -h if [ "${1:-}" = "--" ]; then shift; fi; cargo run -p codex-state --bin logs_client -- "$@" Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s Running `target/debug/logs_client -h` Tail Codex logs from state.sqlite with simple filters Usage: logs_client [OPTIONS] Options: --codex-home Path to CODEX_HOME. Defaults to $CODEX_HOME or ~/.codex [env: CODEX_HOME=] --db Direct path to the SQLite database. Overrides --codex-home --level Log level to match exactly (case-insensitive) --from Start timestamp (RFC3339 or unix seconds) --to End timestamp (RFC3339 or unix seconds) --module Substring match on module_path --file Substring match on file path --backfill Number of matching rows to show before tailing [default: 200] --poll-ms Poll interval in milliseconds [default: 500] -h, --help Print help ``` --- codex-rs/Cargo.lock | 2 + codex-rs/state/Cargo.toml | 2 + codex-rs/state/src/bin/logs_client.rs | 293 ++++++++++++++++++++++++++ justfile | 4 + 4 files changed, 301 insertions(+) create mode 100644 codex-rs/state/src/bin/logs_client.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0ae6c60d42..f47e054ba6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1851,8 +1851,10 @@ version = "0.0.0" dependencies = [ "anyhow", "chrono", + "clap", "codex-otel", "codex-protocol", + "dirs", "pretty_assertions", "serde", "serde_json", diff --git a/codex-rs/state/Cargo.toml b/codex-rs/state/Cargo.toml index b8c7fce0e3..a6e36a3e00 100644 --- a/codex-rs/state/Cargo.toml +++ b/codex-rs/state/Cargo.toml @@ -7,8 +7,10 @@ license.workspace = true [dependencies] anyhow = { workspace = true } chrono = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } codex-otel = { workspace = true } codex-protocol = { workspace = true } +dirs = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sqlx = { workspace = true } diff --git a/codex-rs/state/src/bin/logs_client.rs b/codex-rs/state/src/bin/logs_client.rs new file mode 100644 index 0000000000..13bcb1571d --- /dev/null +++ b/codex-rs/state/src/bin/logs_client.rs @@ -0,0 +1,293 @@ +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use chrono::DateTime; +use chrono::SecondsFormat; +use chrono::Utc; +use clap::Parser; +use codex_state::STATE_DB_FILENAME; +use dirs::home_dir; +use sqlx::QueryBuilder; +use sqlx::Row; +use sqlx::Sqlite; +use sqlx::SqlitePool; +use sqlx::sqlite::SqliteConnectOptions; +use sqlx::sqlite::SqlitePoolOptions; + +#[derive(Debug, Parser)] +#[command(name = "codex-state-logs")] +#[command(about = "Tail Codex logs from state.sqlite with simple filters")] +struct Args { + /// Path to CODEX_HOME. Defaults to $CODEX_HOME or ~/.codex. + #[arg(long, env = "CODEX_HOME")] + codex_home: Option, + + /// Direct path to the SQLite database. Overrides --codex-home. + #[arg(long)] + db: Option, + + /// Log level to match exactly (case-insensitive). + #[arg(long)] + level: Option, + + /// Start timestamp (RFC3339 or unix seconds). + #[arg(long, value_name = "RFC3339|UNIX")] + from: Option, + + /// End timestamp (RFC3339 or unix seconds). + #[arg(long, value_name = "RFC3339|UNIX")] + to: Option, + + /// Substring match on module_path. + #[arg(long)] + module: Option, + + /// Substring match on file path. + #[arg(long)] + file: Option, + + /// Number of matching rows to show before tailing. + #[arg(long, default_value_t = 200)] + backfill: usize, + + /// Poll interval in milliseconds. + #[arg(long, default_value_t = 500)] + poll_ms: u64, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +struct LogRow { + id: i64, + ts: i64, + ts_nanos: i64, + level: String, + message: Option, + fields_json: String, + module_path: Option, + file: Option, + line: Option, +} + +#[derive(Debug, Clone)] +struct LogFilter { + level_upper: Option, + from_ts: Option, + to_ts: Option, + module_like: Option, + file_like: Option, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let db_path = resolve_db_path(&args)?; + let filter = build_filter(&args)?; + let pool = open_read_only_pool(db_path.as_path()).await?; + + let mut last_id = print_backfill(&pool, &filter, args.backfill).await?; + if last_id == 0 { + last_id = fetch_max_id(&pool, &filter).await?; + } + + let poll_interval = Duration::from_millis(args.poll_ms); + loop { + let rows = fetch_new_rows(&pool, &filter, last_id).await?; + for row in rows { + last_id = last_id.max(row.id); + println!("{}", format_row(&row)); + } + tokio::time::sleep(poll_interval).await; + } +} + +fn resolve_db_path(args: &Args) -> anyhow::Result { + if let Some(db) = args.db.as_ref() { + return Ok(db.clone()); + } + + let codex_home = args.codex_home.clone().unwrap_or_else(default_codex_home); + Ok(codex_home.join(STATE_DB_FILENAME)) +} + +fn default_codex_home() -> PathBuf { + if let Some(home) = home_dir() { + return home.join(".codex"); + } + PathBuf::from(".codex") +} + +fn build_filter(args: &Args) -> anyhow::Result { + let from_ts = args + .from + .as_deref() + .map(parse_timestamp) + .transpose() + .context("failed to parse --from")?; + let to_ts = args + .to + .as_deref() + .map(parse_timestamp) + .transpose() + .context("failed to parse --to")?; + + let level_upper = args.level.as_ref().map(|level| level.to_ascii_uppercase()); + + Ok(LogFilter { + level_upper, + from_ts, + to_ts, + module_like: args.module.clone(), + file_like: args.file.clone(), + }) +} + +fn parse_timestamp(value: &str) -> anyhow::Result { + if let Ok(secs) = value.parse::() { + return Ok(secs); + } + + let dt = DateTime::parse_from_rfc3339(value) + .with_context(|| format!("expected RFC3339 or unix seconds, got {value}"))?; + Ok(dt.timestamp()) +} + +async fn open_read_only_pool(path: &Path) -> anyhow::Result { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .read_only(true) + .busy_timeout(Duration::from_secs(5)); + + let display = path.display(); + SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .with_context(|| format!("failed to open sqlite db at {display}")) +} + +async fn print_backfill( + pool: &SqlitePool, + filter: &LogFilter, + backfill: usize, +) -> anyhow::Result { + if backfill == 0 { + return Ok(0); + } + + let mut rows = fetch_backfill(pool, filter, backfill).await?; + rows.reverse(); + + let mut last_id = 0; + for row in rows { + last_id = last_id.max(row.id); + println!("{}", format_row(&row)); + } + Ok(last_id) +} + +async fn fetch_backfill( + pool: &SqlitePool, + filter: &LogFilter, + backfill: usize, +) -> anyhow::Result> { + let mut builder = base_select_builder(); + push_filters(&mut builder, filter); + builder.push(" ORDER BY id DESC"); + builder.push(" LIMIT ").push_bind(backfill as i64); + + builder + .build_query_as::() + .fetch_all(pool) + .await + .context("failed to fetch backfill logs") +} + +async fn fetch_new_rows( + pool: &SqlitePool, + filter: &LogFilter, + last_id: i64, +) -> anyhow::Result> { + let mut builder = base_select_builder(); + push_filters(&mut builder, filter); + builder.push(" AND id > ").push_bind(last_id); + builder.push(" ORDER BY id ASC"); + + builder + .build_query_as::() + .fetch_all(pool) + .await + .context("failed to fetch new logs") +} + +async fn fetch_max_id(pool: &SqlitePool, filter: &LogFilter) -> anyhow::Result { + let mut builder = QueryBuilder::::new("SELECT MAX(id) AS max_id FROM logs WHERE 1 = 1"); + push_filters(&mut builder, filter); + + let row = builder + .build() + .fetch_one(pool) + .await + .context("failed to fetch max log id")?; + let max_id: Option = row.try_get("max_id")?; + Ok(max_id.unwrap_or(0)) +} + +fn base_select_builder<'a>() -> QueryBuilder<'a, Sqlite> { + QueryBuilder::::new( + "SELECT id, ts, ts_nanos, level, message, fields_json, module_path, file, line FROM logs WHERE 1 = 1", + ) +} + +fn push_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, filter: &'a LogFilter) { + if let Some(level_upper) = filter.level_upper.as_ref() { + builder + .push(" AND UPPER(level) = ") + .push_bind(level_upper.as_str()); + } + if let Some(from_ts) = filter.from_ts { + builder.push(" AND ts >= ").push_bind(from_ts); + } + if let Some(to_ts) = filter.to_ts { + builder.push(" AND ts <= ").push_bind(to_ts); + } + if let Some(module_like) = filter.module_like.as_ref() { + builder + .push(" AND module_path LIKE '%' || ") + .push_bind(module_like.as_str()) + .push(" || '%'"); + } + if let Some(file_like) = filter.file_like.as_ref() { + builder + .push(" AND file LIKE '%' || ") + .push_bind(file_like.as_str()) + .push(" || '%'"); + } +} + +fn format_row(row: &LogRow) -> String { + let timestamp = format_timestamp(row.ts, row.ts_nanos); + let location = match (&row.file, row.line) { + (Some(file), Some(line)) => format!("{file}:{line}"), + (Some(file), None) => file.clone(), + _ => "-".to_string(), + }; + let module = row.module_path.as_deref().unwrap_or("-"); + let message = row.message.as_deref().unwrap_or(""); + let fields = row.fields_json.as_str(); + let level = row.level.as_str(); + if fields == "{}" || fields.is_empty() { + return format!("{timestamp} {level:<5} [{module}] {location} - {message}"); + } + format!("{timestamp} {level:<5} [{module}] {location} - {message} {fields}") +} + +fn format_timestamp(ts: i64, ts_nanos: i64) -> String { + let nanos = u32::try_from(ts_nanos).unwrap_or(0); + match DateTime::::from_timestamp(ts, nanos) { + Some(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, true), + None => format!("{ts}.{ts_nanos:09}Z"), + } +} diff --git a/justfile b/justfile index 0e01ef7e1d..8fb5a7d941 100644 --- a/justfile +++ b/justfile @@ -67,3 +67,7 @@ mcp-server-run *args: # Regenerate the json schema for config.toml from the current config types. write-config-schema: cargo run -p codex-core --bin codex-write-config-schema + +# Tail logs from the state SQLite database +log *args: + if [ "${1:-}" = "--" ]; then shift; fi; cargo run -p codex-state --bin logs_client -- "$@" From 4ba911d48c1f5c554bd447b35b42238ef3c1b419 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 29 Jan 2026 11:25:22 +0100 Subject: [PATCH 19/70] chore: improve client (#10149) Screenshot 2026-01-29 at 11 13 12 --- codex-rs/Cargo.lock | 1 + codex-rs/state/Cargo.toml | 1 + codex-rs/state/migrations/0002_logs.sql | 1 - codex-rs/state/src/bin/logs_client.rs | 36 ++++++++++++++++++------- codex-rs/state/src/log_db.rs | 33 +++++++++-------------- codex-rs/state/src/model/log.rs | 1 - codex-rs/state/src/runtime.rs | 3 +-- 7 files changed, 43 insertions(+), 33 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f47e054ba6..4c321b3f62 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1855,6 +1855,7 @@ dependencies = [ "codex-otel", "codex-protocol", "dirs", + "owo-colors", "pretty_assertions", "serde", "serde_json", diff --git a/codex-rs/state/Cargo.toml b/codex-rs/state/Cargo.toml index a6e36a3e00..6019b5888b 100644 --- a/codex-rs/state/Cargo.toml +++ b/codex-rs/state/Cargo.toml @@ -11,6 +11,7 @@ clap = { workspace = true, features = ["derive", "env"] } codex-otel = { workspace = true } codex-protocol = { workspace = true } dirs = { workspace = true } +owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sqlx = { workspace = true } diff --git a/codex-rs/state/migrations/0002_logs.sql b/codex-rs/state/migrations/0002_logs.sql index 4a193500b9..b9a2c681d4 100644 --- a/codex-rs/state/migrations/0002_logs.sql +++ b/codex-rs/state/migrations/0002_logs.sql @@ -5,7 +5,6 @@ CREATE TABLE logs ( level TEXT NOT NULL, target TEXT NOT NULL, message TEXT, - fields_json TEXT NOT NULL, module_path TEXT, file TEXT, line INTEGER diff --git a/codex-rs/state/src/bin/logs_client.rs b/codex-rs/state/src/bin/logs_client.rs index 13bcb1571d..71acf67e85 100644 --- a/codex-rs/state/src/bin/logs_client.rs +++ b/codex-rs/state/src/bin/logs_client.rs @@ -9,6 +9,7 @@ use chrono::Utc; use clap::Parser; use codex_state::STATE_DB_FILENAME; use dirs::home_dir; +use owo_colors::OwoColorize; use sqlx::QueryBuilder; use sqlx::Row; use sqlx::Sqlite; @@ -64,8 +65,6 @@ struct LogRow { ts_nanos: i64, level: String, message: Option, - fields_json: String, - module_path: Option, file: Option, line: Option, } @@ -237,7 +236,7 @@ async fn fetch_max_id(pool: &SqlitePool, filter: &LogFilter) -> anyhow::Result() -> QueryBuilder<'a, Sqlite> { QueryBuilder::::new( - "SELECT id, ts, ts_nanos, level, message, fields_json, module_path, file, line FROM logs WHERE 1 = 1", + "SELECT id, ts, ts_nanos, level, message, file, line FROM logs WHERE 1 = 1", ) } @@ -269,19 +268,38 @@ fn push_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, filter: &'a LogFilte fn format_row(row: &LogRow) -> String { let timestamp = format_timestamp(row.ts, row.ts_nanos); + let level = row.level.as_str(); let location = match (&row.file, row.line) { (Some(file), Some(line)) => format!("{file}:{line}"), (Some(file), None) => file.clone(), _ => "-".to_string(), }; - let module = row.module_path.as_deref().unwrap_or("-"); let message = row.message.as_deref().unwrap_or(""); - let fields = row.fields_json.as_str(); - let level = row.level.as_str(); - if fields == "{}" || fields.is_empty() { - return format!("{timestamp} {level:<5} [{module}] {location} - {message}"); + let level_colored = color_level(level); + let timestamp_colored = timestamp.dimmed().to_string(); + let location_colored = location.dimmed().to_string(); + let message_colored = message.bold().to_string(); + format!("{timestamp_colored} {level_colored} {location_colored} - {message_colored}") +} + +fn color_level(level: &str) -> String { + let padded = format!("{level:<5}"); + if level.eq_ignore_ascii_case("error") { + return padded.red().bold().to_string(); } - format!("{timestamp} {level:<5} [{module}] {location} - {message} {fields}") + if level.eq_ignore_ascii_case("warn") { + return padded.yellow().bold().to_string(); + } + if level.eq_ignore_ascii_case("info") { + return padded.green().bold().to_string(); + } + if level.eq_ignore_ascii_case("debug") { + return padded.blue().bold().to_string(); + } + if level.eq_ignore_ascii_case("trace") { + return padded.magenta().bold().to_string(); + } + padded.bold().to_string() } fn format_timestamp(ts: i64, ts_nanos: i64) -> String { diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index c03b7977d3..13f19d0baf 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -22,7 +22,6 @@ use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; -use serde_json::Value; use tokio::sync::mpsc; use tracing::Event; use tracing::field::Field; @@ -54,7 +53,7 @@ where { fn on_event(&self, event: &Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) { let metadata = event.metadata(); - let mut visitor = JsonVisitor::default(); + let mut visitor = MessageVisitor::default(); event.record(&mut visitor); let now = SystemTime::now() @@ -66,7 +65,6 @@ where level: metadata.level().as_str().to_string(), target: metadata.target().to_string(), message: visitor.message, - fields_json: Value::Object(visitor.fields).to_string(), module_path: metadata.module_path().map(ToString::to_string), file: metadata.file().map(ToString::to_string), line: metadata.line().map(|line| line as i64), @@ -114,49 +112,44 @@ async fn flush(state_db: &std::sync::Arc, buffer: &mut Vec, +struct MessageVisitor { message: Option, } -impl JsonVisitor { - fn record_value(&mut self, field: &Field, value: Value) { +impl MessageVisitor { + fn record_message(&mut self, field: &Field, value: String) { if field.name() == "message" && self.message.is_none() { - self.message = Some(match &value { - Value::String(message) => message.clone(), - _ => value.to_string(), - }); + self.message = Some(value); } - self.fields.insert(field.name().to_string(), value); } } -impl Visit for JsonVisitor { +impl Visit for MessageVisitor { fn record_i64(&mut self, field: &Field, value: i64) { - self.record_value(field, Value::from(value)); + self.record_message(field, value.to_string()); } fn record_u64(&mut self, field: &Field, value: u64) { - self.record_value(field, Value::from(value)); + self.record_message(field, value.to_string()); } fn record_bool(&mut self, field: &Field, value: bool) { - self.record_value(field, Value::from(value)); + self.record_message(field, value.to_string()); } fn record_f64(&mut self, field: &Field, value: f64) { - self.record_value(field, Value::from(value)); + self.record_message(field, value.to_string()); } fn record_str(&mut self, field: &Field, value: &str) { - self.record_value(field, Value::from(value)); + self.record_message(field, value.to_string()); } fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) { - self.record_value(field, Value::from(value.to_string())); + self.record_message(field, value.to_string()); } fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { - self.record_value(field, Value::from(format!("{value:?}"))); + self.record_message(field, format!("{value:?}")); } } diff --git a/codex-rs/state/src/model/log.rs b/codex-rs/state/src/model/log.rs index 5662a3725c..0fa4c0b38e 100644 --- a/codex-rs/state/src/model/log.rs +++ b/codex-rs/state/src/model/log.rs @@ -7,7 +7,6 @@ pub struct LogEntry { pub level: String, pub target: String, pub message: Option, - pub fields_json: String, pub module_path: Option, pub file: Option, pub line: Option, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 34add1347d..96b2d69122 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -214,7 +214,7 @@ FROM threads } let mut builder = QueryBuilder::::new( - "INSERT INTO logs (ts, ts_nanos, level, target, message, fields_json, module_path, file, line) ", + "INSERT INTO logs (ts, ts_nanos, level, target, message, module_path, file, line) ", ); builder.push_values(entries, |mut row, entry| { row.push_bind(entry.ts) @@ -222,7 +222,6 @@ FROM threads .push_bind(&entry.level) .push_bind(&entry.target) .push_bind(&entry.message) - .push_bind(&entry.fields_json) .push_bind(&entry.module_path) .push_bind(&entry.file) .push_bind(entry.line); From d29129f352d02458fc69bb06066e1339d46e0e4c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 29 Jan 2026 15:08:22 +0100 Subject: [PATCH 20/70] nit: update npm (#10161) --- .github/workflows/shell-tool-mcp.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index d6c85d9ddc..57bfdd5f5b 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -451,6 +451,10 @@ jobs: registry-url: https://registry.npmjs.org scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarball uses: actions/download-artifact@v7 with: From 2945667dcc7b655fc3bf044f04b69f6cbdf31b69 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 29 Jan 2026 15:46:15 +0100 Subject: [PATCH 21/70] [experimental] nit: try to speed up apt-install (#10163) --- .github/workflows/shell-tool-mcp.yml | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 57bfdd5f5b..3273bb47bc 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -248,8 +248,32 @@ jobs: run: | set -euo pipefail if command -v apt-get >/dev/null 2>&1; then - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + # On Ubuntu ARM containers, apt metadata for universe/multiverse is large and + # can make `apt-get update` very slow. We only need packages from `main`, so + # trim sources to `main` for faster, more reliable updates. + if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-musl" ]] && [[ -f /etc/os-release ]]; then + . /etc/os-release + if [[ "${ID-}" == "ubuntu" ]]; then + codename="${VERSION_CODENAME:-}" + if [[ -n "$codename" ]]; then + printf '%s\n' \ + "deb http://ports.ubuntu.com/ubuntu-ports ${codename} main" \ + "deb http://ports.ubuntu.com/ubuntu-ports ${codename}-updates main" \ + "deb http://ports.ubuntu.com/ubuntu-ports ${codename}-backports main" \ + "deb http://ports.ubuntu.com/ubuntu-ports ${codename}-security main" \ + >/etc/apt/sources.list + fi + fi + fi + + apt_opts=( + -o Acquire::Retries=3 + -o Acquire::http::Timeout=30 + -o Acquire::https::Timeout=30 + -o Acquire::Languages=none + ) + apt-get "${apt_opts[@]}" update + DEBIAN_FRONTEND=noninteractive apt-get "${apt_opts[@]}" install -y git build-essential bison autoconf gettext elif command -v dnf >/dev/null 2>&1; then dnf install -y git gcc gcc-c++ make bison autoconf gettext elif command -v yum >/dev/null 2>&1; then From b654b7a9aeb8e222e56bbe2814587ca6fafadac5 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 29 Jan 2026 15:59:56 +0100 Subject: [PATCH 22/70] [experimental] nit: try to speed up apt-install 2 (#10164) --- .github/workflows/shell-tool-mcp.yml | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 3273bb47bc..ec0c16cd5e 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -254,14 +254,19 @@ jobs: if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-musl" ]] && [[ -f /etc/os-release ]]; then . /etc/os-release if [[ "${ID-}" == "ubuntu" ]]; then - codename="${VERSION_CODENAME:-}" - if [[ -n "$codename" ]]; then - printf '%s\n' \ - "deb http://ports.ubuntu.com/ubuntu-ports ${codename} main" \ - "deb http://ports.ubuntu.com/ubuntu-ports ${codename}-updates main" \ - "deb http://ports.ubuntu.com/ubuntu-ports ${codename}-backports main" \ - "deb http://ports.ubuntu.com/ubuntu-ports ${codename}-security main" \ - >/etc/apt/sources.list + if [[ -f /etc/apt/sources.list.d/ubuntu.sources ]]; then + # Ubuntu 24.04+ uses deb822 sources; trim components to main. + sed -i -E 's/^Components:.*/Components: main/' /etc/apt/sources.list.d/ubuntu.sources + else + codename="${VERSION_CODENAME:-}" + if [[ -n "$codename" ]]; then + printf '%s\n' \ + "deb http://ports.ubuntu.com/ubuntu-ports ${codename} main" \ + "deb http://ports.ubuntu.com/ubuntu-ports ${codename}-updates main" \ + "deb http://ports.ubuntu.com/ubuntu-ports ${codename}-backports main" \ + "deb http://ports.ubuntu.com/ubuntu-ports ${codename}-security main" \ + >/etc/apt/sources.list + fi fi fi fi From 89c5f3c4d43b174054bd5299d0b507a26c70f465 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 29 Jan 2026 16:53:30 +0100 Subject: [PATCH 23/70] feat: adding thread ID to logs + filter in the client (#10150) --- codex-rs/core/src/codex.rs | 8 +- codex-rs/core/src/shell_snapshot.rs | 28 +++-- codex-rs/core/src/tasks/mod.rs | 41 ++++--- codex-rs/state/migrations/0002_logs.sql | 2 + codex-rs/state/src/bin/logs_client.rs | 20 +++- codex-rs/state/src/log_db.rs | 142 ++++++++++++++++++++++-- codex-rs/state/src/model/log.rs | 1 + codex-rs/state/src/runtime.rs | 3 +- 8 files changed, 203 insertions(+), 42 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index dbc2d5c632..8e1ec64370 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -84,6 +84,7 @@ use tracing::debug; use tracing::error; use tracing::field; use tracing::info; +use tracing::info_span; use tracing::instrument; use tracing::trace_span; use tracing::warn; @@ -353,6 +354,7 @@ impl Codex { let session_source_clone = session_configuration.session_source.clone(); let (agent_status_tx, agent_status_rx) = watch::channel(AgentStatus::PendingInit); + let session_init_span = info_span!("session_init"); let session = Session::new( session_configuration, config.clone(), @@ -366,6 +368,7 @@ impl Codex { skills_manager, agent_control, ) + .instrument(session_init_span) .await .map_err(|e| { error!("Failed to create session: {e:#}"); @@ -374,7 +377,10 @@ impl Codex { let thread_id = session.conversation_id; // This task will run until Op::Shutdown is received. - tokio::spawn(submission_loop(Arc::clone(&session), config, rx_sub)); + let session_loop_span = info_span!("session_loop", thread_id = %thread_id); + tokio::spawn( + submission_loop(Arc::clone(&session), config, rx_sub).instrument(session_loop_span), + ); let codex = Codex { next_id: AtomicU64::new(0), tx_sub, diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 78581186c7..d6d738508a 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -19,6 +19,8 @@ use tokio::fs; use tokio::process::Command; use tokio::sync::watch; use tokio::time::timeout; +use tracing::Instrument; +use tracing::info_span; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ShellSnapshot { @@ -42,17 +44,21 @@ impl ShellSnapshot { let snapshot_shell = shell.clone(); let snapshot_session_id = session_id; - tokio::spawn(async move { - let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]); - let snapshot = - ShellSnapshot::try_new(&codex_home, snapshot_session_id, &snapshot_shell) - .await - .map(Arc::new); - let success = if snapshot.is_some() { "true" } else { "false" }; - let _ = timer.map(|timer| timer.record(&[("success", success)])); - otel_manager.counter("codex.shell_snapshot", 1, &[("success", success)]); - let _ = shell_snapshot_tx.send(snapshot); - }); + let snapshot_span = info_span!("shell_snapshot", thread_id = %snapshot_session_id); + tokio::spawn( + async move { + let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]); + let snapshot = + ShellSnapshot::try_new(&codex_home, snapshot_session_id, &snapshot_shell) + .await + .map(Arc::new); + let success = if snapshot.is_some() { "true" } else { "false" }; + let _ = timer.map(|timer| timer.record(&[("success", success)])); + otel_manager.counter("codex.shell_snapshot", 1, &[("success", success)]); + let _ = shell_snapshot_tx.send(snapshot); + } + .instrument(snapshot_span), + ); } async fn try_new(codex_home: &Path, session_id: ThreadId, shell: &Shell) -> Option { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 08d23f7987..1f1195236c 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -13,6 +13,8 @@ use tokio::select; use tokio::sync::Notify; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; +use tracing::Instrument; +use tracing::info_span; use tracing::trace; use tracing::warn; @@ -130,25 +132,30 @@ impl Session { let ctx = Arc::clone(&turn_context); let task_for_run = Arc::clone(&task); let task_cancellation_token = cancellation_token.child_token(); - tokio::spawn(async move { - let ctx_for_finish = Arc::clone(&ctx); - let last_agent_message = task_for_run - .run( - Arc::clone(&session_ctx), - ctx, - input, - task_cancellation_token.child_token(), - ) - .await; - session_ctx.clone_session().flush_rollout().await; - if !task_cancellation_token.is_cancelled() { - // Emit completion uniformly from spawn site so all tasks share the same lifecycle. - let sess = session_ctx.clone_session(); - sess.on_task_finished(ctx_for_finish, last_agent_message) + let thread_id = self.conversation_id; + let session_span = info_span!("session_task", thread_id = %thread_id); + tokio::spawn( + async move { + let ctx_for_finish = Arc::clone(&ctx); + let last_agent_message = task_for_run + .run( + Arc::clone(&session_ctx), + ctx, + input, + task_cancellation_token.child_token(), + ) .await; + session_ctx.clone_session().flush_rollout().await; + if !task_cancellation_token.is_cancelled() { + // Emit completion uniformly from spawn site so all tasks share the same lifecycle. + let sess = session_ctx.clone_session(); + sess.on_task_finished(ctx_for_finish, last_agent_message) + .await; + } + done_clone.notify_waiters(); } - done_clone.notify_waiters(); - }) + .instrument(session_span), + ) }; let timer = turn_context diff --git a/codex-rs/state/migrations/0002_logs.sql b/codex-rs/state/migrations/0002_logs.sql index b9a2c681d4..051bef9136 100644 --- a/codex-rs/state/migrations/0002_logs.sql +++ b/codex-rs/state/migrations/0002_logs.sql @@ -5,9 +5,11 @@ CREATE TABLE logs ( level TEXT NOT NULL, target TEXT NOT NULL, message TEXT, + thread_id TEXT, module_path TEXT, file TEXT, line INTEGER ); CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC); +CREATE INDEX idx_logs_thread_id ON logs(thread_id); diff --git a/codex-rs/state/src/bin/logs_client.rs b/codex-rs/state/src/bin/logs_client.rs index 71acf67e85..df57856115 100644 --- a/codex-rs/state/src/bin/logs_client.rs +++ b/codex-rs/state/src/bin/logs_client.rs @@ -49,6 +49,10 @@ struct Args { #[arg(long)] file: Option, + /// Match a specific thread id. + #[arg(long)] + thread_id: Option, + /// Number of matching rows to show before tailing. #[arg(long, default_value_t = 200)] backfill: usize, @@ -65,6 +69,7 @@ struct LogRow { ts_nanos: i64, level: String, message: Option, + thread_id: Option, file: Option, line: Option, } @@ -76,6 +81,7 @@ struct LogFilter { to_ts: Option, module_like: Option, file_like: Option, + thread_id: Option, } #[tokio::main] @@ -139,6 +145,7 @@ fn build_filter(args: &Args) -> anyhow::Result { to_ts, module_like: args.module.clone(), file_like: args.file.clone(), + thread_id: args.thread_id.clone(), }) } @@ -236,7 +243,7 @@ async fn fetch_max_id(pool: &SqlitePool, filter: &LogFilter) -> anyhow::Result() -> QueryBuilder<'a, Sqlite> { QueryBuilder::::new( - "SELECT id, ts, ts_nanos, level, message, file, line FROM logs WHERE 1 = 1", + "SELECT id, ts, ts_nanos, level, message, thread_id, file, line FROM logs WHERE 1 = 1", ) } @@ -264,6 +271,11 @@ fn push_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, filter: &'a LogFilte .push_bind(file_like.as_str()) .push(" || '%'"); } + if let Some(thread_id) = filter.thread_id.as_ref() { + builder + .push(" AND thread_id = ") + .push_bind(thread_id.as_str()); + } } fn format_row(row: &LogRow) -> String { @@ -277,9 +289,13 @@ fn format_row(row: &LogRow) -> String { let message = row.message.as_deref().unwrap_or(""); let level_colored = color_level(level); let timestamp_colored = timestamp.dimmed().to_string(); + let thread_id = row.thread_id.as_deref().unwrap_or("-"); + let thread_id_colored = thread_id.yellow().to_string(); let location_colored = location.dimmed().to_string(); let message_colored = message.bold().to_string(); - format!("{timestamp_colored} {level_colored} {location_colored} - {message_colored}") + format!( + "{timestamp_colored} {level_colored} [{thread_id_colored}] {location_colored} - {message_colored}" + ) } fn color_level(level: &str) -> String { diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index 13f19d0baf..a62870974c 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -26,6 +26,9 @@ use tokio::sync::mpsc; use tracing::Event; use tracing::field::Field; use tracing::field::Visit; +use tracing::span::Attributes; +use tracing::span::Id; +use tracing::span::Record; use tracing_subscriber::Layer; use tracing_subscriber::registry::LookupSpan; @@ -51,10 +54,55 @@ impl Layer for LogDbLayer where S: tracing::Subscriber + for<'a> LookupSpan<'a>, { - fn on_event(&self, event: &Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) { + fn on_new_span( + &self, + attrs: &Attributes<'_>, + id: &Id, + ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let mut visitor = SpanFieldVisitor::default(); + attrs.record(&mut visitor); + + if let Some(span) = ctx.span(id) { + span.extensions_mut().insert(SpanLogContext { + thread_id: visitor.thread_id, + }); + } + } + + fn on_record( + &self, + id: &Id, + values: &Record<'_>, + ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let mut visitor = SpanFieldVisitor::default(); + values.record(&mut visitor); + + if visitor.thread_id.is_none() { + return; + } + + if let Some(span) = ctx.span(id) { + let mut extensions = span.extensions_mut(); + if let Some(log_context) = extensions.get_mut::() { + log_context.thread_id = visitor.thread_id; + } else { + extensions.insert(SpanLogContext { + thread_id: visitor.thread_id, + }); + } + } + } + + fn on_event(&self, event: &Event<'_>, ctx: tracing_subscriber::layer::Context<'_, S>) { let metadata = event.metadata(); let mut visitor = MessageVisitor::default(); event.record(&mut visitor); + let thread_id = visitor + .thread_id + .clone() + .or_else(|| event_thread_id(event, &ctx)); let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -65,6 +113,7 @@ where level: metadata.level().as_str().to_string(), target: metadata.target().to_string(), message: visitor.message, + thread_id, module_path: metadata.module_path().map(ToString::to_string), file: metadata.file().map(ToString::to_string), line: metadata.line().map(|line| line as i64), @@ -74,6 +123,75 @@ where } } +#[derive(Clone, Debug, Default)] +struct SpanLogContext { + thread_id: Option, +} + +#[derive(Default)] +struct SpanFieldVisitor { + thread_id: Option, +} + +impl SpanFieldVisitor { + fn record_field(&mut self, field: &Field, value: String) { + if field.name() == "thread_id" && self.thread_id.is_none() { + self.thread_id = Some(value); + } + } +} + +impl Visit for SpanFieldVisitor { + fn record_i64(&mut self, field: &Field, value: i64) { + self.record_field(field, value.to_string()); + } + + fn record_u64(&mut self, field: &Field, value: u64) { + self.record_field(field, value.to_string()); + } + + fn record_bool(&mut self, field: &Field, value: bool) { + self.record_field(field, value.to_string()); + } + + fn record_f64(&mut self, field: &Field, value: f64) { + self.record_field(field, value.to_string()); + } + + fn record_str(&mut self, field: &Field, value: &str) { + self.record_field(field, value.to_string()); + } + + fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) { + self.record_field(field, value.to_string()); + } + + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.record_field(field, format!("{value:?}")); + } +} + +fn event_thread_id( + event: &Event<'_>, + ctx: &tracing_subscriber::layer::Context<'_, S>, +) -> Option +where + S: tracing::Subscriber + for<'a> LookupSpan<'a>, +{ + let mut thread_id = None; + if let Some(scope) = ctx.event_scope(event) { + for span in scope.from_root() { + let extensions = span.extensions(); + if let Some(log_context) = extensions.get::() + && log_context.thread_id.is_some() + { + thread_id = log_context.thread_id.clone(); + } + } + } + thread_id +} + async fn run_inserter( state_db: std::sync::Arc, mut receiver: mpsc::Receiver, @@ -114,42 +232,46 @@ async fn flush(state_db: &std::sync::Arc, buffer: &mut Vec, + thread_id: Option, } impl MessageVisitor { - fn record_message(&mut self, field: &Field, value: String) { + fn record_field(&mut self, field: &Field, value: String) { if field.name() == "message" && self.message.is_none() { - self.message = Some(value); + self.message = Some(value.clone()); + } + if field.name() == "thread_id" && self.thread_id.is_none() { + self.thread_id = Some(value); } } } impl Visit for MessageVisitor { fn record_i64(&mut self, field: &Field, value: i64) { - self.record_message(field, value.to_string()); + self.record_field(field, value.to_string()); } fn record_u64(&mut self, field: &Field, value: u64) { - self.record_message(field, value.to_string()); + self.record_field(field, value.to_string()); } fn record_bool(&mut self, field: &Field, value: bool) { - self.record_message(field, value.to_string()); + self.record_field(field, value.to_string()); } fn record_f64(&mut self, field: &Field, value: f64) { - self.record_message(field, value.to_string()); + self.record_field(field, value.to_string()); } fn record_str(&mut self, field: &Field, value: &str) { - self.record_message(field, value.to_string()); + self.record_field(field, value.to_string()); } fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) { - self.record_message(field, value.to_string()); + self.record_field(field, value.to_string()); } fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { - self.record_message(field, format!("{value:?}")); + self.record_field(field, format!("{value:?}")); } } diff --git a/codex-rs/state/src/model/log.rs b/codex-rs/state/src/model/log.rs index 0fa4c0b38e..c0c3a2dff2 100644 --- a/codex-rs/state/src/model/log.rs +++ b/codex-rs/state/src/model/log.rs @@ -7,6 +7,7 @@ pub struct LogEntry { pub level: String, pub target: String, pub message: Option, + pub thread_id: Option, pub module_path: Option, pub file: Option, pub line: Option, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 96b2d69122..8dada32746 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -214,7 +214,7 @@ FROM threads } let mut builder = QueryBuilder::::new( - "INSERT INTO logs (ts, ts_nanos, level, target, message, module_path, file, line) ", + "INSERT INTO logs (ts, ts_nanos, level, target, message, thread_id, module_path, file, line) ", ); builder.push_values(entries, |mut row, entry| { row.push_bind(entry.ts) @@ -222,6 +222,7 @@ FROM threads .push_bind(&entry.level) .push_bind(&entry.target) .push_bind(&entry.message) + .push_bind(&entry.thread_id) .push_bind(&entry.module_path) .push_bind(&entry.file) .push_bind(entry.line); From d6631fb5a99087dd0fb7c37620e1f32977cc5c7e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 29 Jan 2026 16:55:01 +0100 Subject: [PATCH 24/70] feat: add log retention and delete them after 90 days (#10151) --- codex-rs/state/src/log_db.rs | 14 +++++++++++++- codex-rs/state/src/runtime.rs | 8 ++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index a62870974c..345e90c6a8 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -18,6 +18,8 @@ //! # } //! ``` +use chrono::Duration as ChronoDuration; +use chrono::Utc; use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -38,6 +40,7 @@ use crate::StateRuntime; const LOG_QUEUE_CAPACITY: usize = 512; const LOG_BATCH_SIZE: usize = 64; const LOG_FLUSH_INTERVAL: Duration = Duration::from_millis(250); +const LOG_RETENTION_DAYS: i64 = 90; pub struct LogDbLayer { sender: mpsc::Sender, @@ -45,7 +48,8 @@ pub struct LogDbLayer { pub fn start(state_db: std::sync::Arc) -> LogDbLayer { let (sender, receiver) = mpsc::channel(LOG_QUEUE_CAPACITY); - tokio::spawn(run_inserter(state_db, receiver)); + tokio::spawn(run_inserter(std::sync::Arc::clone(&state_db), receiver)); + tokio::spawn(run_retention_cleanup(state_db)); LogDbLayer { sender } } @@ -229,6 +233,14 @@ async fn flush(state_db: &std::sync::Arc, buffer: &mut Vec) { + let Some(cutoff) = Utc::now().checked_sub_signed(ChronoDuration::days(LOG_RETENTION_DAYS)) + else { + return; + }; + let _ = state_db.delete_logs_before(cutoff.timestamp()).await; +} + #[derive(Default)] struct MessageVisitor { message: Option, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 8dada32746..b61ea315c5 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -231,6 +231,14 @@ FROM threads Ok(()) } + pub(crate) async fn delete_logs_before(&self, cutoff_ts: i64) -> anyhow::Result { + let result = sqlx::query("DELETE FROM logs WHERE ts < ?") + .bind(cutoff_ts) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected()) + } + /// List thread ids using the underlying database (no rollout scanning). pub async fn list_thread_ids( &self, From e6c4f548ab3072b6ae709b3da5aecfb074fe95d7 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 29 Jan 2026 17:28:15 +0100 Subject: [PATCH 25/70] chore: unify log queries (#10152) Unify log queries to only have SQLX code in the runtime and use it for both the log client and for tests --- .github/workflows/shell-tool-mcp.yml | 37 +---- codex-rs/core/tests/suite/sqlite_state.rs | 80 ++++++++++ codex-rs/state/migrations/0002_logs.sql | 2 - .../state/migrations/0003_logs_thread_id.sql | 3 + codex-rs/state/src/bin/logs_client.rs | 144 +++++------------- codex-rs/state/src/lib.rs | 2 + codex-rs/state/src/model/log.rs | 26 ++++ codex-rs/state/src/model/mod.rs | 2 + codex-rs/state/src/runtime.rs | 68 +++++++++ 9 files changed, 225 insertions(+), 139 deletions(-) create mode 100644 codex-rs/state/migrations/0003_logs_thread_id.sql diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index ec0c16cd5e..d6c85d9ddc 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -248,37 +248,8 @@ jobs: run: | set -euo pipefail if command -v apt-get >/dev/null 2>&1; then - # On Ubuntu ARM containers, apt metadata for universe/multiverse is large and - # can make `apt-get update` very slow. We only need packages from `main`, so - # trim sources to `main` for faster, more reliable updates. - if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-musl" ]] && [[ -f /etc/os-release ]]; then - . /etc/os-release - if [[ "${ID-}" == "ubuntu" ]]; then - if [[ -f /etc/apt/sources.list.d/ubuntu.sources ]]; then - # Ubuntu 24.04+ uses deb822 sources; trim components to main. - sed -i -E 's/^Components:.*/Components: main/' /etc/apt/sources.list.d/ubuntu.sources - else - codename="${VERSION_CODENAME:-}" - if [[ -n "$codename" ]]; then - printf '%s\n' \ - "deb http://ports.ubuntu.com/ubuntu-ports ${codename} main" \ - "deb http://ports.ubuntu.com/ubuntu-ports ${codename}-updates main" \ - "deb http://ports.ubuntu.com/ubuntu-ports ${codename}-backports main" \ - "deb http://ports.ubuntu.com/ubuntu-ports ${codename}-security main" \ - >/etc/apt/sources.list - fi - fi - fi - fi - - apt_opts=( - -o Acquire::Retries=3 - -o Acquire::http::Timeout=30 - -o Acquire::https::Timeout=30 - -o Acquire::Languages=none - ) - apt-get "${apt_opts[@]}" update - DEBIAN_FRONTEND=noninteractive apt-get "${apt_opts[@]}" install -y git build-essential bison autoconf gettext + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext elif command -v dnf >/dev/null 2>&1; then dnf install -y git gcc gcc-c++ make bison autoconf gettext elif command -v yum >/dev/null 2>&1; then @@ -480,10 +451,6 @@ jobs: registry-url: https://registry.npmjs.org scope: "@openai" - # Trusted publishing requires npm CLI version 11.5.1 or later. - - name: Update npm - run: npm install -g npm@latest - - name: Download npm tarball uses: actions/download-artifact@v7 with: diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index df6fa2ef72..bf3b5a4f51 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -10,12 +10,18 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::UserMessageEvent; use codex_state::STATE_DB_FILENAME; use core_test_support::load_sse_fixture_with_id; +use core_test_support::responses; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use pretty_assertions::assert_eq; +use serde_json::json; use std::fs; use tokio::time::Duration; +use tracing_subscriber::prelude::*; use uuid::Uuid; fn sse_completed(id: &str) -> String { @@ -197,3 +203,77 @@ async fn user_messages_persist_in_state_db() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "current_thread")] +async fn tool_call_logs_include_thread_id() -> Result<()> { + let server = start_mock_server().await; + let call_id = "call-1"; + let args = json!({ + "command": "echo hello", + "timeout_ms": 1_000, + "login": false, + }); + let args_json = serde_json::to_string(&args)?; + mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell_command", &args_json), + ev_completed("resp-1"), + ]), + responses::sse(vec![ev_completed("resp-2")]), + ], + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::Sqlite); + }); + let test = builder.build(&server).await?; + let db = test.codex.state_db().expect("state db enabled"); + let expected_thread_id = test.session_configured.session_id.to_string(); + + let subscriber = tracing_subscriber::registry().with(codex_state::log_db::start(db.clone())); + let dispatch = tracing::Dispatch::new(subscriber); + let _guard = tracing::dispatcher::set_default(&dispatch); + + test.submit_turn("run a shell command").await?; + { + let span = tracing::info_span!("test_log_span", thread_id = %expected_thread_id); + let _entered = span.enter(); + tracing::info!("ToolCall: shell_command {{\"command\":\"echo hello\"}}"); + } + + let mut found = None; + for _ in 0..80 { + let query = codex_state::LogQuery { + descending: true, + limit: Some(20), + ..Default::default() + }; + let rows = db.query_logs(&query).await?; + if let Some(row) = rows.into_iter().find(|row| { + row.message + .as_deref() + .is_some_and(|m| m.starts_with("ToolCall:")) + }) { + let thread_id = row.thread_id; + let message = row.message; + found = Some((thread_id, message)); + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + + let (thread_id, message) = found.expect("expected ToolCall log row"); + assert_eq!(thread_id, Some(expected_thread_id)); + assert!( + message + .as_deref() + .is_some_and(|text| text.starts_with("ToolCall:")), + "expected ToolCall message, got {message:?}" + ); + + Ok(()) +} diff --git a/codex-rs/state/migrations/0002_logs.sql b/codex-rs/state/migrations/0002_logs.sql index 051bef9136..b9a2c681d4 100644 --- a/codex-rs/state/migrations/0002_logs.sql +++ b/codex-rs/state/migrations/0002_logs.sql @@ -5,11 +5,9 @@ CREATE TABLE logs ( level TEXT NOT NULL, target TEXT NOT NULL, message TEXT, - thread_id TEXT, module_path TEXT, file TEXT, line INTEGER ); CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC); -CREATE INDEX idx_logs_thread_id ON logs(thread_id); diff --git a/codex-rs/state/migrations/0003_logs_thread_id.sql b/codex-rs/state/migrations/0003_logs_thread_id.sql new file mode 100644 index 0000000000..c4badb6885 --- /dev/null +++ b/codex-rs/state/migrations/0003_logs_thread_id.sql @@ -0,0 +1,3 @@ +ALTER TABLE logs ADD COLUMN thread_id TEXT; + +CREATE INDEX idx_logs_thread_id ON logs(thread_id); diff --git a/codex-rs/state/src/bin/logs_client.rs b/codex-rs/state/src/bin/logs_client.rs index df57856115..c02a423253 100644 --- a/codex-rs/state/src/bin/logs_client.rs +++ b/codex-rs/state/src/bin/logs_client.rs @@ -1,4 +1,3 @@ -use std::path::Path; use std::path::PathBuf; use std::time::Duration; @@ -7,15 +6,12 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use clap::Parser; +use codex_state::LogQuery; +use codex_state::LogRow; use codex_state::STATE_DB_FILENAME; +use codex_state::StateRuntime; use dirs::home_dir; use owo_colors::OwoColorize; -use sqlx::QueryBuilder; -use sqlx::Row; -use sqlx::Sqlite; -use sqlx::SqlitePool; -use sqlx::sqlite::SqliteConnectOptions; -use sqlx::sqlite::SqlitePoolOptions; #[derive(Debug, Parser)] #[command(name = "codex-state-logs")] @@ -62,18 +58,6 @@ struct Args { poll_ms: u64, } -#[derive(Debug, Clone, sqlx::FromRow)] -struct LogRow { - id: i64, - ts: i64, - ts_nanos: i64, - level: String, - message: Option, - thread_id: Option, - file: Option, - line: Option, -} - #[derive(Debug, Clone)] struct LogFilter { level_upper: Option, @@ -89,16 +73,20 @@ async fn main() -> anyhow::Result<()> { let args = Args::parse(); let db_path = resolve_db_path(&args)?; let filter = build_filter(&args)?; - let pool = open_read_only_pool(db_path.as_path()).await?; + let codex_home = db_path + .parent() + .map(ToOwned::to_owned) + .unwrap_or_else(|| PathBuf::from(".")); + let runtime = StateRuntime::init(codex_home, "logs-client".to_string(), None).await?; - let mut last_id = print_backfill(&pool, &filter, args.backfill).await?; + let mut last_id = print_backfill(runtime.as_ref(), &filter, args.backfill).await?; if last_id == 0 { - last_id = fetch_max_id(&pool, &filter).await?; + last_id = fetch_max_id(runtime.as_ref(), &filter).await?; } let poll_interval = Duration::from_millis(args.poll_ms); loop { - let rows = fetch_new_rows(&pool, &filter, last_id).await?; + let rows = fetch_new_rows(runtime.as_ref(), &filter, last_id).await?; for row in rows { last_id = last_id.max(row.id); println!("{}", format_row(&row)); @@ -159,23 +147,8 @@ fn parse_timestamp(value: &str) -> anyhow::Result { Ok(dt.timestamp()) } -async fn open_read_only_pool(path: &Path) -> anyhow::Result { - let options = SqliteConnectOptions::new() - .filename(path) - .create_if_missing(false) - .read_only(true) - .busy_timeout(Duration::from_secs(5)); - - let display = path.display(); - SqlitePoolOptions::new() - .max_connections(1) - .connect_with(options) - .await - .with_context(|| format!("failed to open sqlite db at {display}")) -} - async fn print_backfill( - pool: &SqlitePool, + runtime: &StateRuntime, filter: &LogFilter, backfill: usize, ) -> anyhow::Result { @@ -183,7 +156,7 @@ async fn print_backfill( return Ok(0); } - let mut rows = fetch_backfill(pool, filter, backfill).await?; + let mut rows = fetch_backfill(runtime, filter, backfill).await?; rows.reverse(); let mut last_id = 0; @@ -195,86 +168,53 @@ async fn print_backfill( } async fn fetch_backfill( - pool: &SqlitePool, + runtime: &StateRuntime, filter: &LogFilter, backfill: usize, ) -> anyhow::Result> { - let mut builder = base_select_builder(); - push_filters(&mut builder, filter); - builder.push(" ORDER BY id DESC"); - builder.push(" LIMIT ").push_bind(backfill as i64); - - builder - .build_query_as::() - .fetch_all(pool) + let query = to_log_query(filter, Some(backfill), None, true); + runtime + .query_logs(&query) .await .context("failed to fetch backfill logs") } async fn fetch_new_rows( - pool: &SqlitePool, + runtime: &StateRuntime, filter: &LogFilter, last_id: i64, ) -> anyhow::Result> { - let mut builder = base_select_builder(); - push_filters(&mut builder, filter); - builder.push(" AND id > ").push_bind(last_id); - builder.push(" ORDER BY id ASC"); - - builder - .build_query_as::() - .fetch_all(pool) + let query = to_log_query(filter, None, Some(last_id), false); + runtime + .query_logs(&query) .await .context("failed to fetch new logs") } -async fn fetch_max_id(pool: &SqlitePool, filter: &LogFilter) -> anyhow::Result { - let mut builder = QueryBuilder::::new("SELECT MAX(id) AS max_id FROM logs WHERE 1 = 1"); - push_filters(&mut builder, filter); - - let row = builder - .build() - .fetch_one(pool) +async fn fetch_max_id(runtime: &StateRuntime, filter: &LogFilter) -> anyhow::Result { + let query = to_log_query(filter, None, None, false); + runtime + .max_log_id(&query) .await - .context("failed to fetch max log id")?; - let max_id: Option = row.try_get("max_id")?; - Ok(max_id.unwrap_or(0)) + .context("failed to fetch max log id") } -fn base_select_builder<'a>() -> QueryBuilder<'a, Sqlite> { - QueryBuilder::::new( - "SELECT id, ts, ts_nanos, level, message, thread_id, file, line FROM logs WHERE 1 = 1", - ) -} - -fn push_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, filter: &'a LogFilter) { - if let Some(level_upper) = filter.level_upper.as_ref() { - builder - .push(" AND UPPER(level) = ") - .push_bind(level_upper.as_str()); - } - if let Some(from_ts) = filter.from_ts { - builder.push(" AND ts >= ").push_bind(from_ts); - } - if let Some(to_ts) = filter.to_ts { - builder.push(" AND ts <= ").push_bind(to_ts); - } - if let Some(module_like) = filter.module_like.as_ref() { - builder - .push(" AND module_path LIKE '%' || ") - .push_bind(module_like.as_str()) - .push(" || '%'"); - } - if let Some(file_like) = filter.file_like.as_ref() { - builder - .push(" AND file LIKE '%' || ") - .push_bind(file_like.as_str()) - .push(" || '%'"); - } - if let Some(thread_id) = filter.thread_id.as_ref() { - builder - .push(" AND thread_id = ") - .push_bind(thread_id.as_str()); +fn to_log_query( + filter: &LogFilter, + limit: Option, + after_id: Option, + descending: bool, +) -> LogQuery { + LogQuery { + level_upper: filter.level_upper.clone(), + from_ts: filter.from_ts, + to_ts: filter.to_ts, + module_like: filter.module_like.clone(), + file_like: filter.file_like.clone(), + thread_id: filter.thread_id.clone(), + after_id, + limit, + descending, } } diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 91ebacea71..685552015e 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -12,6 +12,8 @@ mod paths; mod runtime; pub use model::LogEntry; +pub use model::LogQuery; +pub use model::LogRow; /// Preferred entrypoint: owns configuration and metrics. pub use runtime::StateRuntime; diff --git a/codex-rs/state/src/model/log.rs b/codex-rs/state/src/model/log.rs index c0c3a2dff2..d8a9ba3524 100644 --- a/codex-rs/state/src/model/log.rs +++ b/codex-rs/state/src/model/log.rs @@ -1,4 +1,5 @@ use serde::Serialize; +use sqlx::FromRow; #[derive(Clone, Debug, Serialize)] pub struct LogEntry { @@ -12,3 +13,28 @@ pub struct LogEntry { pub file: Option, pub line: Option, } + +#[derive(Clone, Debug, FromRow)] +pub struct LogRow { + pub id: i64, + pub ts: i64, + pub ts_nanos: i64, + pub level: String, + pub message: Option, + pub thread_id: Option, + pub file: Option, + pub line: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct LogQuery { + pub level_upper: Option, + pub from_ts: Option, + pub to_ts: Option, + pub module_like: Option, + pub file_like: Option, + pub thread_id: Option, + pub after_id: Option, + pub limit: Option, + pub descending: bool, +} diff --git a/codex-rs/state/src/model/mod.rs b/codex-rs/state/src/model/mod.rs index 4921e2357a..bd615d7561 100644 --- a/codex-rs/state/src/model/mod.rs +++ b/codex-rs/state/src/model/mod.rs @@ -2,6 +2,8 @@ mod log; mod thread_metadata; pub use log::LogEntry; +pub use log::LogQuery; +pub use log::LogRow; pub use thread_metadata::Anchor; pub use thread_metadata::BackfillStats; pub use thread_metadata::ExtractionOutcome; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index b61ea315c5..b15e4338bf 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -1,5 +1,7 @@ use crate::DB_ERROR_METRIC; use crate::LogEntry; +use crate::LogQuery; +use crate::LogRow; use crate::SortKey; use crate::ThreadMetadata; use crate::ThreadMetadataBuilder; @@ -239,6 +241,38 @@ FROM threads Ok(result.rows_affected()) } + /// Query logs with optional filters. + pub async fn query_logs(&self, query: &LogQuery) -> anyhow::Result> { + let mut builder = QueryBuilder::::new( + "SELECT id, ts, ts_nanos, level, message, thread_id, file, line FROM logs WHERE 1 = 1", + ); + push_log_filters(&mut builder, query); + if query.descending { + builder.push(" ORDER BY id DESC"); + } else { + builder.push(" ORDER BY id ASC"); + } + if let Some(limit) = query.limit { + builder.push(" LIMIT ").push_bind(limit as i64); + } + + let rows = builder + .build_query_as::() + .fetch_all(self.pool.as_ref()) + .await?; + Ok(rows) + } + + /// Return the max log id matching optional filters. + pub async fn max_log_id(&self, query: &LogQuery) -> anyhow::Result { + let mut builder = + QueryBuilder::::new("SELECT MAX(id) AS max_id FROM logs WHERE 1 = 1"); + push_log_filters(&mut builder, query); + let row = builder.build().fetch_one(self.pool.as_ref()).await?; + let max_id: Option = row.try_get("max_id")?; + Ok(max_id.unwrap_or(0)) + } + /// List thread ids using the underlying database (no rollout scanning). pub async fn list_thread_ids( &self, @@ -411,6 +445,40 @@ ON CONFLICT(id) DO UPDATE SET } } +fn push_log_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, query: &'a LogQuery) { + if let Some(level_upper) = query.level_upper.as_ref() { + builder + .push(" AND UPPER(level) = ") + .push_bind(level_upper.as_str()); + } + if let Some(from_ts) = query.from_ts { + builder.push(" AND ts >= ").push_bind(from_ts); + } + if let Some(to_ts) = query.to_ts { + builder.push(" AND ts <= ").push_bind(to_ts); + } + if let Some(module_like) = query.module_like.as_ref() { + builder + .push(" AND module_path LIKE '%' || ") + .push_bind(module_like.as_str()) + .push(" || '%'"); + } + if let Some(file_like) = query.file_like.as_ref() { + builder + .push(" AND file LIKE '%' || ") + .push_bind(file_like.as_str()) + .push(" || '%'"); + } + if let Some(thread_id) = query.thread_id.as_ref() { + builder + .push(" AND thread_id = ") + .push_bind(thread_id.as_str()); + } + if let Some(after_id) = query.after_id { + builder.push(" AND id > ").push_bind(after_id); + } +} + async fn open_sqlite(path: &Path) -> anyhow::Result { let options = SqliteConnectOptions::new() .filename(path) From 3e798c5a7dd0556947acc2baea1bb64be63ff3c7 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Thu, 29 Jan 2026 09:34:59 -0800 Subject: [PATCH 26/70] Add OpenAI docs MCP tooltip (#10175) --- codex-rs/tui/tooltips.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 31819c6628..dc2d2faa67 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -9,6 +9,7 @@ Use /status to see the current model, approvals, and token usage. Use /fork to branch the current chat into a new thread. Use /init to create an AGENTS.md with project-specific guidance. Use /mcp to list configured MCP tools. +Use the OpenAI docs MCP for API questions; enable it with `codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp`. You can run any shell command from Codex using `!` (e.g. `!ls`) Type / to open the command popup; Tab autocompletes slash commands. When the composer is empty, press Esc to step back and edit your last message; Enter confirms. From 798c4b3260e857756c2d6e13cc9bcd05ba604c7e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 29 Jan 2026 19:15:22 +0100 Subject: [PATCH 27/70] feat: reduce span exposition (#10171) This only avoids the creation of duplicates spans --- codex-rs/core/src/stream_events_utils.rs | 7 ++++++- codex-rs/core/src/tasks/mod.rs | 5 ++--- codex-rs/state/src/bin/logs_client.rs | 12 ++++-------- codex-rs/state/src/model/log.rs | 1 + codex-rs/state/src/runtime.rs | 2 +- codex-rs/tui/src/lib.rs | 5 ++++- 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 197ff6b4b6..930ce5afd6 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -51,7 +51,12 @@ pub(crate) async fn handle_output_item_done( // The model emitted a tool call; log it, persist the item immediately, and queue the tool execution. Ok(Some(call)) => { let payload_preview = call.payload.log_payload().into_owned(); - tracing::info!("ToolCall: {} {}", call.tool_name, payload_preview); + tracing::info!( + thread_id = %ctx.sess.conversation_id, + "ToolCall: {} {}", + call.tool_name, + payload_preview + ); ctx.sess .record_conversation_items(&ctx.turn_context, std::slice::from_ref(&item)) diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 1f1195236c..a487041eba 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -14,7 +14,7 @@ use tokio::sync::Notify; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; use tracing::Instrument; -use tracing::info_span; +use tracing::Span; use tracing::trace; use tracing::warn; @@ -132,8 +132,7 @@ impl Session { let ctx = Arc::clone(&turn_context); let task_for_run = Arc::clone(&task); let task_cancellation_token = cancellation_token.child_token(); - let thread_id = self.conversation_id; - let session_span = info_span!("session_task", thread_id = %thread_id); + let session_span = Span::current(); tokio::spawn( async move { let ctx_for_finish = Arc::clone(&ctx); diff --git a/codex-rs/state/src/bin/logs_client.rs b/codex-rs/state/src/bin/logs_client.rs index c02a423253..4389c29bc6 100644 --- a/codex-rs/state/src/bin/logs_client.rs +++ b/codex-rs/state/src/bin/logs_client.rs @@ -221,20 +221,16 @@ fn to_log_query( fn format_row(row: &LogRow) -> String { let timestamp = format_timestamp(row.ts, row.ts_nanos); let level = row.level.as_str(); - let location = match (&row.file, row.line) { - (Some(file), Some(line)) => format!("{file}:{line}"), - (Some(file), None) => file.clone(), - _ => "-".to_string(), - }; + let target = row.target.as_str(); let message = row.message.as_deref().unwrap_or(""); let level_colored = color_level(level); let timestamp_colored = timestamp.dimmed().to_string(); let thread_id = row.thread_id.as_deref().unwrap_or("-"); - let thread_id_colored = thread_id.yellow().to_string(); - let location_colored = location.dimmed().to_string(); + let thread_id_colored = thread_id.blue().dimmed().to_string(); + let target_colored = target.dimmed().to_string(); let message_colored = message.bold().to_string(); format!( - "{timestamp_colored} {level_colored} [{thread_id_colored}] {location_colored} - {message_colored}" + "{timestamp_colored} {level_colored} [{thread_id_colored}] {target_colored} - {message_colored}" ) } diff --git a/codex-rs/state/src/model/log.rs b/codex-rs/state/src/model/log.rs index d8a9ba3524..5d774249dd 100644 --- a/codex-rs/state/src/model/log.rs +++ b/codex-rs/state/src/model/log.rs @@ -20,6 +20,7 @@ pub struct LogRow { pub ts: i64, pub ts_nanos: i64, pub level: String, + pub target: String, pub message: Option, pub thread_id: Option, pub file: Option, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index b15e4338bf..e0805e5b65 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -244,7 +244,7 @@ FROM threads /// Query logs with optional filters. pub async fn query_logs(&self, query: &LogQuery) -> anyhow::Result> { let mut builder = QueryBuilder::::new( - "SELECT id, ts, ts_nanos, level, message, thread_id, file, line FROM logs WHERE 1 = 1", + "SELECT id, ts, ts_nanos, level, target, message, thread_id, file, line FROM logs WHERE 1 = 1", ); push_log_filters(&mut builder, query); if query.descending { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c9e69b7125..010f8c83e5 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -306,7 +306,10 @@ pub async fn run_main( // grep for a specific module/target while troubleshooting. .with_target(true) .with_ansi(false) - .with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL) + .with_span_events( + tracing_subscriber::fmt::format::FmtSpan::NEW + | tracing_subscriber::fmt::format::FmtSpan::CLOSE, + ) .with_filter(env_filter()); let feedback = codex_feedback::CodexFeedback::new(); From 3b1cddf00106065a8ed0d95d76a206f0ce66ebf3 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 29 Jan 2026 10:36:21 -0800 Subject: [PATCH 28/70] Fall back to http when websockets fail (#10139) I expect not all proxies work with websockets, fall back to http if websockets fail. --- codex-rs/core/src/client.rs | 33 ++++++- codex-rs/core/src/codex.rs | 25 ++++- codex-rs/core/src/lib.rs | 2 + codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/tools/handlers/collab.rs | 3 + codex-rs/core/src/transport_manager.rs | 31 ++++++ .../core/tests/chat_completions_payload.rs | 2 + codex-rs/core/tests/chat_completions_sse.rs | 2 + codex-rs/core/tests/responses_headers.rs | 4 + codex-rs/core/tests/suite/client.rs | 2 + .../core/tests/suite/client_websockets.rs | 2 + codex-rs/core/tests/suite/mod.rs | 1 + .../core/tests/suite/websocket_fallback.rs | 98 +++++++++++++++++++ 13 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 codex-rs/core/src/transport_manager.rs create mode 100644 codex-rs/core/tests/suite/websocket_fallback.rs diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f415c3604a..98fce871fd 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -65,6 +65,7 @@ use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; +use crate::transport_manager::TransportManager; pub const WEB_SEARCH_ELIGIBLE_HEADER: &str = "x-oai-web-search-eligible"; pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; @@ -80,6 +81,7 @@ struct ModelClientState { effort: Option, summary: ReasoningSummaryConfig, session_source: SessionSource, + transport_manager: TransportManager, } #[derive(Debug, Clone)] @@ -91,6 +93,7 @@ pub struct ModelClientSession { state: Arc, connection: Option, websocket_last_items: Vec, + transport_manager: TransportManager, /// Turn state for sticky routing. /// /// This is an `OnceLock` that stores the turn state value received from the server @@ -116,6 +119,7 @@ impl ModelClient { summary: ReasoningSummaryConfig, conversation_id: ThreadId, session_source: SessionSource, + transport_manager: TransportManager, ) -> Self { Self { state: Arc::new(ModelClientState { @@ -128,6 +132,7 @@ impl ModelClient { effort, summary, session_source, + transport_manager, }), } } @@ -137,6 +142,7 @@ impl ModelClient { state: Arc::clone(&self.state), connection: None, websocket_last_items: Vec::new(), + transport_manager: self.state.transport_manager.clone(), turn_state: Arc::new(OnceLock::new()), } } @@ -171,6 +177,10 @@ impl ModelClient { self.state.session_source.clone() } + pub(crate) fn transport_manager(&self) -> TransportManager { + self.state.transport_manager.clone() + } + /// Returns the currently configured model slug. pub fn get_model(&self) -> String { self.state.model_info.slug.clone() @@ -250,7 +260,10 @@ impl ModelClientSession { /// For Chat providers, the underlying stream is optionally aggregated /// based on the `show_raw_agent_reasoning` flag in the config. pub async fn stream(&mut self, prompt: &Prompt) -> Result { - match self.state.provider.wire_api { + let wire_api = self + .transport_manager + .effective_wire_api(self.state.provider.wire_api); + match wire_api { WireApi::Responses => self.stream_responses_api(prompt).await, WireApi::ResponsesWebsocket => self.stream_responses_websocket(prompt).await, WireApi::Chat => { @@ -271,6 +284,24 @@ impl ModelClientSession { } } + pub(crate) fn try_switch_fallback_transport(&mut self) -> bool { + let activated = self + .transport_manager + .activate_http_fallback(self.state.provider.wire_api); + if activated { + warn!("falling back to HTTP"); + self.state.otel_manager.counter( + "codex.transport.fallback_to_http", + 1, + &[("from_wire_api", "responses_websocket")], + ); + + self.connection = None; + self.websocket_last_items.clear(); + } + activated + } + fn build_responses_request(&self, prompt: &Prompt) -> Result { let instructions = prompt.base_instructions.text.clone(); let tools_json: Vec = create_tools_json_for_responses_api(&prompt.tools)?; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8e1ec64370..736d0554b3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -30,6 +30,7 @@ use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::handle_non_tool_response_item; use crate::stream_events_utils::handle_output_item_done; use crate::terminal; +use crate::transport_manager::TransportManager; use crate::truncate::TruncationPolicy; use crate::user_notification::UserNotifier; use crate::util::error_or_panic; @@ -624,6 +625,7 @@ impl Session { model_info: ModelInfo, conversation_id: ThreadId, sub_id: String, + transport_manager: TransportManager, ) -> TurnContext { let otel_manager = otel_manager.clone().with_model( session_configuration.collaboration_mode.model(), @@ -640,6 +642,7 @@ impl Session { session_configuration.model_reasoning_summary, conversation_id, session_configuration.session_source.clone(), + transport_manager, ); let tools_config = ToolsConfig::new(&ToolsConfigParams { @@ -869,6 +872,7 @@ impl Session { skills_manager, agent_control, state_db: state_db_ctx.clone(), + transport_manager: TransportManager::new(), }; let sess = Arc::new(Session { @@ -1188,6 +1192,7 @@ impl Session { model_info, self.conversation_id, sub_id, + self.services.transport_manager.clone(), ); if let Some(final_schema) = final_output_json_schema { turn_context.final_output_json_schema = final_schema; @@ -3042,6 +3047,7 @@ async fn spawn_review_thread( per_turn_config.model_reasoning_summary, sess.conversation_id, parent_turn_context.client.get_session_source(), + parent_turn_context.client.transport_manager(), ); let review_turn_context = TurnContext { @@ -3537,7 +3543,9 @@ async fn run_sampling_request( ) .await { - Ok(output) => return Ok(output), + Ok(output) => { + return Ok(output); + } Err(CodexErr::ContextWindowExceeded) => { sess.set_total_tokens_full(&turn_context).await; return Err(CodexErr::ContextWindowExceeded); @@ -3558,6 +3566,17 @@ async fn run_sampling_request( // Use the configured provider-specific stream retry budget. let max_retries = turn_context.client.get_provider().stream_max_retries(); + if retries >= max_retries && client_session.try_switch_fallback_transport() { + sess.send_event( + &turn_context, + EventMsg::Warning(WarningEvent { + message: format!("Falling back from WebSockets to HTTPS transport. {err:#}"), + }), + ) + .await; + retries = 0; + continue; + } if retries < max_retries { retries += 1; let delay = match &err { @@ -4753,6 +4772,7 @@ mod tests { skills_manager, agent_control, state_db: None, + transport_manager: TransportManager::new(), }; let turn_context = Session::make_turn_context( @@ -4764,6 +4784,7 @@ mod tests { model_info, conversation_id, "turn_id".to_string(), + services.transport_manager.clone(), ); let session = Session { @@ -4865,6 +4886,7 @@ mod tests { skills_manager, agent_control, state_db: None, + transport_manager: TransportManager::new(), }; let turn_context = Arc::new(Session::make_turn_context( @@ -4876,6 +4898,7 @@ mod tests { model_info, conversation_id, "turn_id".to_string(), + services.transport_manager.clone(), )); let session = Arc::new(Session { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ab51a201dc..1b1fb65ecd 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -38,6 +38,7 @@ pub mod landlock; pub mod mcp; mod mcp_connection_manager; pub mod models_manager; +mod transport_manager; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; @@ -113,6 +114,7 @@ pub use rollout::list::parse_cursor; pub use rollout::list::read_head_for_summary; pub use rollout::list::read_session_meta_line; pub use rollout::rollout_date_parts; +pub use transport_manager::TransportManager; mod function_tool; mod state; mod tasks; diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 6559ec2e18..e036b29b7d 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -9,6 +9,7 @@ use crate::models_manager::manager::ModelsManager; use crate::skills::SkillsManager; use crate::state_db::StateDbHandle; use crate::tools::sandboxing::ApprovalStore; +use crate::transport_manager::TransportManager; use crate::unified_exec::UnifiedExecProcessManager; use crate::user_notification::UserNotifier; use codex_otel::OtelManager; @@ -32,4 +33,5 @@ pub(crate) struct SessionServices { pub(crate) skills_manager: Arc, pub(crate) agent_control: AgentControl, pub(crate) state_db: Option, + pub(crate) transport_manager: TransportManager, } diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index b1666949f6..61ccc1932e 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -781,6 +781,7 @@ mod tests { turn.client.get_reasoning_summary(), session.conversation_id, session_source, + session.services.transport_manager.clone(), ); let invocation = invocation( @@ -1221,6 +1222,7 @@ mod tests { let mut base_config = (*turn.client.config()).clone(); base_config.user_instructions = Some("base-user".to_string()); turn.user_instructions = Some("resolved-user".to_string()); + let transport_manager = turn.client.transport_manager(); turn.client = ModelClient::new( Arc::new(base_config.clone()), Some(session.services.auth_manager.clone()), @@ -1231,6 +1233,7 @@ mod tests { turn.client.get_reasoning_summary(), session.conversation_id, session_source, + transport_manager, ); let base_instructions = BaseInstructions { text: "base".to_string(), diff --git a/codex-rs/core/src/transport_manager.rs b/codex-rs/core/src/transport_manager.rs new file mode 100644 index 0000000000..f1fe68faf4 --- /dev/null +++ b/codex-rs/core/src/transport_manager.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +use crate::model_provider_info::WireApi; + +#[derive(Clone, Debug, Default)] +pub struct TransportManager { + fallback_to_http: Arc, +} + +impl TransportManager { + pub fn new() -> Self { + Self::default() + } + + pub fn effective_wire_api(&self, provider_wire_api: WireApi) -> WireApi { + if self.fallback_to_http.load(Ordering::Relaxed) + && provider_wire_api == WireApi::ResponsesWebsocket + { + WireApi::Responses + } else { + provider_wire_api + } + } + + pub fn activate_http_fallback(&self, provider_wire_api: WireApi) -> bool { + provider_wire_api == WireApi::ResponsesWebsocket + && !self.fallback_to_http.swap(true, Ordering::Relaxed) + } +} diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index cdb92fe672..5e8c895a3f 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -11,6 +11,7 @@ use codex_core::ModelClient; use codex_core::ModelProviderInfo; use codex_core::Prompt; use codex_core::ResponseItem; +use codex_core::TransportManager; use codex_core::WireApi; use codex_core::models_manager::manager::ModelsManager; use codex_otel::OtelManager; @@ -98,6 +99,7 @@ async fn run_request(input: Vec) -> Value { summary, conversation_id, SessionSource::Exec, + TransportManager::new(), ) .new_session(); diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 05ef476a05..84d0ede085 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -10,6 +10,7 @@ use codex_core::ModelProviderInfo; use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::ResponseItem; +use codex_core::TransportManager; use codex_core::WireApi; use codex_core::models_manager::manager::ModelsManager; use codex_otel::OtelManager; @@ -99,6 +100,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { summary, conversation_id, SessionSource::Exec, + TransportManager::new(), ) .new_session(); diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 22d9fa8b79..b3c0165f46 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -9,6 +9,7 @@ use codex_core::ModelProviderInfo; use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::ResponseItem; +use codex_core::TransportManager; use codex_core::WEB_SEARCH_ELIGIBLE_HEADER; use codex_core::WireApi; use codex_core::models_manager::manager::ModelsManager; @@ -94,6 +95,7 @@ async fn responses_stream_includes_subagent_header_on_review() { summary, conversation_id, session_source, + TransportManager::new(), ) .new_session(); @@ -191,6 +193,7 @@ async fn responses_stream_includes_subagent_header_on_other() { summary, conversation_id, session_source, + TransportManager::new(), ) .new_session(); @@ -346,6 +349,7 @@ async fn responses_respects_model_info_overrides_from_config() { summary, conversation_id, session_source, + TransportManager::new(), ) .new_session(); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index bdc2f311c0..8b8adf06ab 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -11,6 +11,7 @@ use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::ResponseItem; use codex_core::ThreadManager; +use codex_core::TransportManager; use codex_core::WireApi; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::built_in_model_providers; @@ -1186,6 +1187,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { summary, conversation_id, SessionSource::Exec, + TransportManager::new(), ) .new_session(); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index d55d71d0d8..60229e01d6 100644 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -8,6 +8,7 @@ use codex_core::ModelProviderInfo; use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::ResponseItem; +use codex_core::TransportManager; use codex_core::WireApi; use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::SessionSource; @@ -228,6 +229,7 @@ async fn websocket_harness(server: &WebSocketTestServer) -> WebsocketTestHarness ReasoningSummary::Auto, conversation_id, SessionSource::Exec, + TransportManager::new(), ); WebsocketTestHarness { diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e054070b34..0744624a92 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -80,3 +80,4 @@ mod user_notification; mod user_shell_cmd; mod view_image; mod web_search_cached; +mod websocket_fallback; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs new file mode 100644 index 0000000000..d52236a8a2 --- /dev/null +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -0,0 +1,98 @@ +use anyhow::Result; +use codex_core::WireApi; +use core_test_support::responses; +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::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; +use wiremock::http::Method; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_fallback_switches_to_http_after_retries_exhausted() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + + let mut builder = test_codex().with_config({ + let base_url = format!("{}/v1", server.uri()); + move |config| { + config.model_provider.base_url = Some(base_url); + config.model_provider.wire_api = WireApi::ResponsesWebsocket; + config.model_provider.stream_max_retries = Some(0); + config.model_provider.request_max_retries = Some(0); + } + }); + let test = builder.build(&server).await?; + + test.submit_turn("hello").await?; + + let requests = server.received_requests().await.unwrap_or_default(); + let websocket_attempts = requests + .iter() + .filter(|req| req.method == Method::GET && req.url.path().ends_with("/responses")) + .count(); + let http_attempts = requests + .iter() + .filter(|req| req.method == Method::POST && req.url.path().ends_with("/responses")) + .count(); + + assert_eq!(websocket_attempts, 1); + assert_eq!(http_attempts, 1); + assert_eq!(response_mock.requests().len(), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_fallback_is_sticky_across_turns() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + + let mut builder = test_codex().with_config({ + let base_url = format!("{}/v1", server.uri()); + move |config| { + config.model_provider.base_url = Some(base_url); + config.model_provider.wire_api = WireApi::ResponsesWebsocket; + config.model_provider.stream_max_retries = Some(0); + config.model_provider.request_max_retries = Some(0); + } + }); + let test = builder.build(&server).await?; + + test.submit_turn("first").await?; + test.submit_turn("second").await?; + + let requests = server.received_requests().await.unwrap_or_default(); + let websocket_attempts = requests + .iter() + .filter(|req| req.method == Method::GET && req.url.path().ends_with("/responses")) + .count(); + let http_attempts = requests + .iter() + .filter(|req| req.method == Method::POST && req.url.path().ends_with("/responses")) + .count(); + + assert_eq!(websocket_attempts, 1); + assert_eq!(http_attempts, 2); + assert_eq!(response_mock.requests().len(), 2); + + Ok(()) +} From b7f26d74f040b8f967b8f1760177d96eb0e9863c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 29 Jan 2026 10:49:03 -0800 Subject: [PATCH 29/70] chore: ensure pnpm-workspace.yaml is up-to-date (#10140) On the back of: https://github.com/openai/codex/pull/10138 Let's ensure that every folder with a `package.json` is listed in `pnpm-workspace.yaml` (not sure why `docs` was in there...) and that we are using `pnpm` over `npm` consistently (which is why this PR deletes `codex-cli/package-lock.json`). --- codex-cli/package-lock.json | 18 ------------------ pnpm-lock.yaml | 4 ++++ pnpm-workspace.yaml | 3 ++- 3 files changed, 6 insertions(+), 19 deletions(-) delete mode 100644 codex-cli/package-lock.json diff --git a/codex-cli/package-lock.json b/codex-cli/package-lock.json deleted file mode 100644 index 58ee846306..0000000000 --- a/codex-cli/package-lock.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@openai/codex", - "version": "0.0.0-dev", - "lockfileVersion": 3, - "packages": { - "": { - "name": "@openai/codex", - "version": "0.0.0-dev", - "license": "Apache-2.0", - "bin": { - "codex": "bin/codex.js" - }, - "engines": { - "node": ">=16" - } - } - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01112d6d3b..102869a683 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,10 @@ importers: specifier: ^3.5.3 version: 3.5.3 + codex-cli: {} + + codex-rs/responses-api-proxy/npm: {} + sdk/typescript: devDependencies: '@modelcontextprotocol/sdk': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 78899b9321..09a61536ab 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - - docs + - codex-cli + - codex-rs/responses-api-proxy/npm - sdk/typescript - shell-tool-mcp From bdd8a7d58bdd4ea57efe17f24cde4c09fdfea0b0 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Thu, 29 Jan 2026 11:13:30 -0800 Subject: [PATCH 30/70] Better handling skill depdenencies on ENV VAR. (#9017) An experimental flow for env var skill dependencies. Skills can now declare required env vars in SKILL.md; if missing, the CLI prompts the user to get the value, and Core will store it in memory (eventually to a local persistent store) image --- .../app-server-protocol/src/protocol/v2.rs | 2 + .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/config.schema.json | 6 + codex-rs/core/src/codex.rs | 21 +++ codex-rs/core/src/features.rs | 8 + codex-rs/core/src/mcp/skill_dependencies.rs | 1 + .../core/src/skills/env_var_dependencies.rs | 162 ++++++++++++++++++ codex-rs/core/src/skills/mod.rs | 3 + codex-rs/core/src/state/session.rs | 13 ++ codex-rs/core/src/tools/handlers/shell.rs | 6 + codex-rs/protocol/src/request_user_input.rs | 4 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 13 +- .../src/bottom_pane/request_user_input/mod.rs | 6 + .../bottom_pane/request_user_input/render.rs | 9 +- codex-rs/tui/src/bottom_pane/textarea.rs | 36 ++++ 15 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 codex-rs/core/src/skills/env_var_dependencies.rs diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7b695d2c7a..247360282c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2532,6 +2532,8 @@ pub struct ToolRequestUserInputQuestion { pub question: String, #[serde(default)] pub is_other: bool, + #[serde(default)] + pub is_secret: bool, pub options: Option>, } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 20e7db762b..1a9fcaa705 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -279,6 +279,7 @@ pub(crate) async fn apply_bespoke_event_handling( header: question.header, question: question.question, is_other: question.is_other, + is_secret: question.is_secret, options: question.options.map(|options| { options .into_iter() diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 717eddc612..16c0c8945c 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -207,6 +207,9 @@ "shell_tool": { "type": "boolean" }, + "skill_env_var_dependency_prompt": { + "type": "boolean" + }, "skill_mcp_dependency_install": { "type": "boolean" }, @@ -1231,6 +1234,9 @@ "shell_tool": { "type": "boolean" }, + "skill_env_var_dependency_prompt": { + "type": "boolean" + }, "skill_mcp_dependency_install": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 736d0554b3..1b7b7312c7 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -168,10 +168,12 @@ use crate::skills::SkillInjections; use crate::skills::SkillMetadata; use crate::skills::SkillsManager; use crate::skills::build_skill_injections; +use crate::skills::collect_env_var_dependencies; use crate::skills::collect_explicit_skill_mentions; use crate::skills::injection::ToolMentionKind; use crate::skills::injection::app_id_from_path; use crate::skills::injection::tool_kind_for_path; +use crate::skills::resolve_skill_dependencies_for_turn; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -1953,6 +1955,16 @@ impl Session { state.record_mcp_dependency_prompted(names); } + pub async fn dependency_env(&self) -> HashMap { + let state = self.state.lock().await; + state.dependency_env() + } + + pub async fn set_dependency_env(&self, values: HashMap) { + let mut state = self.state.lock().await; + state.set_dependency_env(values); + } + pub(crate) async fn set_server_reasoning_included(&self, included: bool) { let mut state = self.state.lock().await; state.set_server_reasoning_included(included); @@ -3218,6 +3230,15 @@ pub(crate) async fn run_turn( }); let explicit_app_paths = collect_explicit_app_paths(&input); + let config = turn_context.client.config(); + if config + .features + .enabled(Feature::SkillEnvVarDependencyPrompt) + { + let env_var_dependencies = collect_env_var_dependencies(&mentioned_skills); + resolve_skill_dependencies_for_turn(&sess, &turn_context, &env_var_dependencies).await; + } + maybe_prompt_and_install_mcp_dependencies( sess.as_ref(), turn_context.as_ref(), diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 9bc52537b7..961b7a56f6 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -115,6 +115,8 @@ pub enum Feature { Apps, /// Allow prompting and installing missing MCP dependencies. SkillMcpDependencyInstall, + /// Prompt for missing skill env var dependencies. + SkillEnvVarDependencyPrompt, /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. Steer, /// Enable collaboration modes (Plan, Code, Pair Programming, Execute). @@ -533,6 +535,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::SkillEnvVarDependencyPrompt, + key: "skill_env_var_dependency_prompt", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Steer, key: "steer", diff --git a/codex-rs/core/src/mcp/skill_dependencies.rs b/codex-rs/core/src/mcp/skill_dependencies.rs index c295a96863..37620d73db 100644 --- a/codex-rs/core/src/mcp/skill_dependencies.rs +++ b/codex-rs/core/src/mcp/skill_dependencies.rs @@ -79,6 +79,7 @@ async fn should_install_mcp_dependencies( "The following MCP servers are required by the selected skills but are not installed yet: {server_list}. Install them now?" ), is_other: false, + is_secret: false, options: Some(vec![ RequestUserInputQuestionOption { label: MCP_DEPENDENCY_OPTION_INSTALL.to_string(), diff --git a/codex-rs/core/src/skills/env_var_dependencies.rs b/codex-rs/core/src/skills/env_var_dependencies.rs new file mode 100644 index 0000000000..00f5bad8cc --- /dev/null +++ b/codex-rs/core/src/skills/env_var_dependencies.rs @@ -0,0 +1,162 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::env; +use std::sync::Arc; + +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputQuestion; +use codex_protocol::request_user_input::RequestUserInputResponse; +use tracing::warn; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::skills::SkillMetadata; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SkillDependencyInfo { + pub(crate) skill_name: String, + pub(crate) name: String, + pub(crate) description: Option, +} + +/// Resolve required dependency values (session cache, then env vars), +/// and prompt the UI for any missing ones. +pub(crate) async fn resolve_skill_dependencies_for_turn( + sess: &Arc, + turn_context: &Arc, + dependencies: &[SkillDependencyInfo], +) { + if dependencies.is_empty() { + return; + } + + let existing_env = sess.dependency_env().await; + let mut loaded_values = HashMap::new(); + let mut missing = Vec::new(); + let mut seen_names = HashSet::new(); + + for dependency in dependencies { + let name = dependency.name.clone(); + if !seen_names.insert(name.clone()) { + continue; + } + if existing_env.contains_key(&name) { + continue; + } + match env::var(&name) { + Ok(value) => { + loaded_values.insert(name.clone(), value); + continue; + } + Err(env::VarError::NotPresent) => {} + Err(err) => { + warn!("failed to read env var {name}: {err}"); + } + } + missing.push(dependency.clone()); + } + + if !loaded_values.is_empty() { + sess.set_dependency_env(loaded_values).await; + } + + if !missing.is_empty() { + request_skill_dependencies(sess, turn_context, &missing).await; + } +} + +pub(crate) fn collect_env_var_dependencies( + mentioned_skills: &[SkillMetadata], +) -> Vec { + let mut dependencies = Vec::new(); + for skill in mentioned_skills { + let Some(skill_dependencies) = &skill.dependencies else { + continue; + }; + for tool in &skill_dependencies.tools { + if tool.r#type != "env_var" { + continue; + } + if tool.value.is_empty() { + continue; + } + dependencies.push(SkillDependencyInfo { + skill_name: skill.name.clone(), + name: tool.value.clone(), + description: tool.description.clone(), + }); + } + } + dependencies +} + +/// Prompt via request_user_input to gather missing env vars. +pub(crate) async fn request_skill_dependencies( + sess: &Arc, + turn_context: &Arc, + dependencies: &[SkillDependencyInfo], +) { + let questions = dependencies + .iter() + .map(|dep| { + let requirement = dep.description.as_ref().map_or_else( + || format!("The skill \"{}\" requires \"{}\" to be set.", dep.skill_name, dep.name), + |description| { + format!( + "The skill \"{}\" requires \"{}\" to be set ({}).", + dep.skill_name, dep.name, description + ) + }, + ); + let question = format!( + "{requirement} This is an experimental internal feature. The value is stored in memory for this session only.", + ); + RequestUserInputQuestion { + id: dep.name.clone(), + header: "Skill requires environment variable".to_string(), + question, + is_other: false, + is_secret: true, + options: None, + } + }) + .collect::>(); + + if questions.is_empty() { + return; + } + + let args = RequestUserInputArgs { questions }; + let call_id = format!("skill-deps-{}", turn_context.sub_id); + let response = sess + .request_user_input(turn_context, call_id, args) + .await + .unwrap_or_else(|| RequestUserInputResponse { + answers: HashMap::new(), + }); + + if response.answers.is_empty() { + return; + } + + let mut values = HashMap::new(); + for (name, answer) in response.answers { + let mut user_note = None; + for entry in &answer.answers { + if let Some(note) = entry.strip_prefix("user_note: ") + && !note.trim().is_empty() + { + user_note = Some(note.trim().to_string()); + } + } + if let Some(value) = user_note { + values.insert(name, value); + } + } + + if values.is_empty() { + return; + } + + sess.set_dependency_env(values).await; +} diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index ae4ffa2920..4d79bca258 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -1,3 +1,4 @@ +mod env_var_dependencies; pub mod injection; pub mod loader; pub mod manager; @@ -5,6 +6,8 @@ pub mod model; pub mod render; pub mod system; +pub(crate) use env_var_dependencies::collect_env_var_dependencies; +pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn; pub(crate) use injection::SkillInjections; pub(crate) use injection::build_skill_injections; pub(crate) use injection::collect_explicit_skill_mentions; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 9d3d96db99..deee0d0c7c 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -1,6 +1,7 @@ //! Session-wide mutable state. use codex_protocol::models::ResponseItem; +use std::collections::HashMap; use std::collections::HashSet; use crate::codex::SessionConfiguration; @@ -16,6 +17,7 @@ pub(crate) struct SessionState { pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, pub(crate) server_reasoning_included: bool, + pub(crate) dependency_env: HashMap, pub(crate) mcp_dependency_prompted: HashSet, /// Whether the session's initial context has been seeded into history. /// @@ -33,6 +35,7 @@ impl SessionState { history, latest_rate_limits: None, server_reasoning_included: false, + dependency_env: HashMap::new(), mcp_dependency_prompted: HashSet::new(), initial_context_seeded: false, } @@ -112,6 +115,16 @@ impl SessionState { pub(crate) fn mcp_dependency_prompted(&self) -> HashSet { self.mcp_dependency_prompted.clone() } + + pub(crate) fn set_dependency_env(&mut self, values: HashMap) { + for (key, value) in values { + self.dependency_env.insert(key, value); + } + } + + pub(crate) fn dependency_env(&self) -> HashMap { + self.dependency_env.clone() + } } // Sometimes new snapshots don't include credits or plan information. diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index dbfb0364bf..b59c0140d1 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -233,6 +233,12 @@ impl ShellHandler { None }; + let mut exec_params = exec_params; + let dependency_env = session.dependency_env().await; + if !dependency_env.is_empty() { + exec_params.env.extend(dependency_env); + } + // Approval policy guard for explicit escalation in non-OnRequest modes. if exec_params .sandbox_permissions diff --git a/codex-rs/protocol/src/request_user_input.rs b/codex-rs/protocol/src/request_user_input.rs index 0e58348da4..cb076264dd 100644 --- a/codex-rs/protocol/src/request_user_input.rs +++ b/codex-rs/protocol/src/request_user_input.rs @@ -20,6 +20,10 @@ pub struct RequestUserInputQuestion { #[schemars(rename = "isOther")] #[ts(rename = "isOther")] pub is_other: bool, + #[serde(rename = "isSecret", default)] + #[schemars(rename = "isSecret")] + #[ts(rename = "isSecret")] + pub is_secret: bool, #[serde(skip_serializing_if = "Option::is_none")] pub options: Option>, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 9d0cbf22e5..90cae4b4a0 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2854,6 +2854,12 @@ impl Renderable for ChatComposer { } fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_with_mask(area, buf, None); + } +} + +impl ChatComposer { + pub(crate) fn render_with_mask(&self, area: Rect, buf: &mut Buffer, mask_char: Option) { let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area); match &self.active_popup { ActivePopup::Command(popup) => { @@ -3018,7 +3024,12 @@ impl Renderable for ChatComposer { } let mut state = self.textarea_state.borrow_mut(); - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + if let Some(mask_char) = mask_char { + self.textarea + .render_ref_masked(textarea_rect, buf, &mut state, mask_char); + } else { + StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + } if self.textarea.text().is_empty() { let text = if self.input_enabled { self.placeholder_text.as_str().to_string() diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 979499835d..8fe3401693 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1251,6 +1251,7 @@ mod tests { header: header.to_string(), question: "Choose an option.".to_string(), is_other: false, + is_secret: false, options: Some(vec![ RequestUserInputQuestionOption { label: "Option 1".to_string(), @@ -1274,6 +1275,7 @@ mod tests { header: header.to_string(), question: "Choose an option.".to_string(), is_other: true, + is_secret: false, options: Some(vec![ RequestUserInputQuestionOption { label: "Option 1".to_string(), @@ -1297,6 +1299,7 @@ mod tests { header: header.to_string(), question: "Choose the next step for this task.".to_string(), is_other: false, + is_secret: false, options: Some(vec![ RequestUserInputQuestionOption { label: "Discuss a code change".to_string(), @@ -1326,6 +1329,7 @@ mod tests { header: header.to_string(), question: "Share details.".to_string(), is_other: false, + is_secret: false, options: None, } } @@ -2385,6 +2389,7 @@ mod tests { header: "Next Step".to_string(), question: "What would you like to do next?".to_string(), is_other: false, + is_secret: false, options: Some(vec![ RequestUserInputQuestionOption { label: "Discuss a code change (Recommended)".to_string(), @@ -2436,6 +2441,7 @@ mod tests { header: "Next Step".to_string(), question: "What would you like to do next?".to_string(), is_other: false, + is_secret: false, options: Some(vec![ RequestUserInputQuestionOption { label: "Discuss a code change (Recommended)".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs index b7433f8a6d..2180ec0553 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -414,7 +414,14 @@ impl RequestUserInputOverlay { if area.width == 0 || area.height == 0 { return; } - self.composer.render(area, buf); + let is_secret = self + .current_question() + .is_some_and(|question| question.is_secret); + if is_secret { + self.composer.render_with_mask(area, buf, Some('*')); + } else { + self.composer.render(area, buf); + } } } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 0235f7a9c1..42f7b09bef 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -1146,6 +1146,22 @@ impl StatefulWidgetRef for &TextArea { } impl TextArea { + pub(crate) fn render_ref_masked( + &self, + area: Rect, + buf: &mut Buffer, + state: &mut TextAreaState, + mask_char: char, + ) { + let lines = self.wrapped_lines(area.width); + let scroll = self.effective_scroll(area.height, &lines, state.scroll); + state.scroll = scroll; + + let start = scroll as usize; + let end = (scroll + area.height).min(lines.len() as u16) as usize; + self.render_lines_masked(area, buf, &lines, start..end, mask_char); + } + fn render_lines( &self, area: Rect, @@ -1175,6 +1191,26 @@ impl TextArea { } } } + + fn render_lines_masked( + &self, + area: Rect, + buf: &mut Buffer, + lines: &[Range], + range: std::ops::Range, + mask_char: char, + ) { + for (row, idx) in range.enumerate() { + let r = &lines[idx]; + let y = area.y + row as u16; + let line_range = r.start..r.end - 1; + let masked = self.text[line_range.clone()] + .chars() + .map(|_| mask_char) + .collect::(); + buf.set_string(area.x, y, &masked, Style::default()); + } + } } #[cfg(test)] From 48f203120d872776bc80ce4327c5066d513a74e9 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 29 Jan 2026 11:51:33 -0800 Subject: [PATCH 31/70] fix: unify `npm publish` call across shell-tool-mcp.yml and rust-release.yml (#10182) We are seeing flakiness in the `npm publish` step for https://www.npmjs.com/package/@openai/codex-shell-tool-mcp, so this is a shot in the dark for a fix: https://github.com/openai/codex/actions/runs/21490679301/job/61913765060 Note this removes `actions/checkout@v6` and `pnpm/action-setup@v4` steps, which I believe are superflous for the `npm publish` call. --- .github/workflows/shell-tool-mcp.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index d6c85d9ddc..6285754fb0 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -436,14 +436,6 @@ jobs: id-token: write contents: read steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - name: Setup Node.js uses: actions/setup-node@v6 with: @@ -451,6 +443,10 @@ jobs: registry-url: https://registry.npmjs.org scope: "@openai" + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Download npm tarball uses: actions/download-artifact@v7 with: From 03aee7140f47826854bccdc3769c095131a0211a Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Thu, 29 Jan 2026 12:35:03 -0800 Subject: [PATCH 32/70] Add features enable/disable subcommands (#10180) ## Summary - add `codex features enable ` and `codex features disable ` - persist feature flag changes to `config.toml` (respecting profile) - print the under-development feature warning when enabling prerelease features - keep `features list` behavior unchanged and add unit/integration tests ## Testing - cargo test -p codex-cli --- codex-rs/cli/src/main.rs | 96 ++++++++++++++++++++++++++++++++++ codex-rs/cli/tests/features.rs | 58 ++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 codex-rs/cli/tests/features.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index faa6cf2fce..dd20f04128 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -39,6 +39,9 @@ use crate::mcp_cmd::McpCli; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::find_codex_home; +use codex_core::features::Stage; use codex_core::features::is_known_feature_key; use codex_core::terminal::TerminalName; @@ -448,6 +451,16 @@ struct FeaturesCli { enum FeaturesSubcommand { /// List known features with their stage and effective state. List, + /// Enable a feature in config.toml. + Enable(FeatureSetArgs), + /// Disable a feature in config.toml. + Disable(FeatureSetArgs), +} + +#[derive(Debug, Parser)] +struct FeatureSetArgs { + /// Feature key to update (for example: unified_exec). + feature: String, } fn stage_str(stage: codex_core::features::Stage) -> &'static str { @@ -711,12 +724,69 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() println!("{name: { + enable_feature_in_config(&interactive, &feature).await?; + } + FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => { + disable_feature_in_config(&interactive, &feature).await?; + } }, } Ok(()) } +async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> { + FeatureToggles::validate_feature(feature)?; + let codex_home = find_codex_home()?; + ConfigEditsBuilder::new(&codex_home) + .with_profile(interactive.config_profile.as_deref()) + .set_feature_enabled(feature, true) + .apply() + .await?; + println!("Enabled feature `{feature}` in config.toml."); + maybe_print_under_development_feature_warning(&codex_home, interactive, feature); + Ok(()) +} + +async fn disable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> { + FeatureToggles::validate_feature(feature)?; + let codex_home = find_codex_home()?; + ConfigEditsBuilder::new(&codex_home) + .with_profile(interactive.config_profile.as_deref()) + .set_feature_enabled(feature, false) + .apply() + .await?; + println!("Disabled feature `{feature}` in config.toml."); + Ok(()) +} + +fn maybe_print_under_development_feature_warning( + codex_home: &std::path::Path, + interactive: &TuiCli, + feature: &str, +) { + if interactive.config_profile.is_some() { + return; + } + + let Some(spec) = codex_core::features::FEATURES + .iter() + .find(|spec| spec.key == feature) + else { + return; + }; + if !matches!(spec.stage, Stage::UnderDevelopment) { + return; + } + + let config_path = codex_home.join(codex_core::config::CONFIG_TOML_FILE); + eprintln!( + "Under-development features enabled: {feature}. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in {}.", + config_path.display() + ); +} + /// Prepend root-level overrides so they have lower precedence than /// CLI-specific ones specified after the subcommand (if any). fn prepend_config_flags( @@ -1171,6 +1241,32 @@ mod tests { assert!(app_server.analytics_default_enabled); } + #[test] + fn features_enable_parses_feature_name() { + let cli = MultitoolCli::try_parse_from(["codex", "features", "enable", "unified_exec"]) + .expect("parse should succeed"); + let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else { + panic!("expected features subcommand"); + }; + let FeaturesSubcommand::Enable(FeatureSetArgs { feature }) = sub else { + panic!("expected features enable"); + }; + assert_eq!(feature, "unified_exec"); + } + + #[test] + fn features_disable_parses_feature_name() { + let cli = MultitoolCli::try_parse_from(["codex", "features", "disable", "shell_tool"]) + .expect("parse should succeed"); + let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else { + panic!("expected features subcommand"); + }; + let FeaturesSubcommand::Disable(FeatureSetArgs { feature }) = sub else { + panic!("expected features disable"); + }; + assert_eq!(feature, "shell_tool"); + } + #[test] fn feature_toggles_known_features_generate_overrides() { let toggles = FeatureToggles { diff --git a/codex-rs/cli/tests/features.rs b/codex-rs/cli/tests/features.rs new file mode 100644 index 0000000000..8fa07e0a49 --- /dev/null +++ b/codex-rs/cli/tests/features.rs @@ -0,0 +1,58 @@ +use std::path::Path; + +use anyhow::Result; +use predicates::str::contains; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[tokio::test] +async fn features_enable_writes_feature_flag_to_config() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["features", "enable", "unified_exec"]) + .assert() + .success() + .stdout(contains("Enabled feature `unified_exec` in config.toml.")); + + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(config.contains("[features]")); + assert!(config.contains("unified_exec = true")); + + Ok(()) +} + +#[tokio::test] +async fn features_disable_writes_feature_flag_to_config() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["features", "disable", "shell_tool"]) + .assert() + .success() + .stdout(contains("Disabled feature `shell_tool` in config.toml.")); + + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(config.contains("[features]")); + assert!(config.contains("shell_tool = false")); + + Ok(()) +} + +#[tokio::test] +async fn features_enable_under_development_feature_prints_warning() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["features", "enable", "sqlite"]) + .assert() + .success() + .stderr(contains("Under-development features enabled: sqlite.")); + + Ok(()) +} From 2d9ac8227ae43ef7232b4f4422efc16cf0105bfb Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 29 Jan 2026 12:36:53 -0800 Subject: [PATCH 33/70] fix: /approvals -> /permissions (#10184) I believe we should be recommending `/permissions` in light of https://github.com/openai/codex/pull/9561. --- codex-rs/tui/tooltips.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index dc2d2faa67..455ae5cd03 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -2,7 +2,7 @@ Use /compact when the conversation gets long to summarize history and free up co Start a fresh idea with /new; the previous session stays in history. Use /feedback to send logs to the maintainers when something looks off. Switch models or reasoning effort quickly with /model. -Use /approvals to control when Codex asks for confirmation. +Use /permissions to control when Codex asks for confirmation. Run /review to get a code review of your current changes. Use /skills to list available skills or ask Codex to use one. Use /status to see the current model, approvals, and token usage. From fbb3a309538180ca4c550ad0d8e75f2bfeae3a07 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 29 Jan 2026 13:50:53 -0800 Subject: [PATCH 34/70] Remove WebSocket wire format (#10179) I'd like WireApi to go away (when chat is removed) and WebSockets is still responses API just over a different transport. --- codex-rs/core/config.schema.json | 12 ++++---- codex-rs/core/src/client.rs | 28 +++++++++++++++---- codex-rs/core/src/config/mod.rs | 11 ++------ codex-rs/core/src/model_provider_info.rs | 17 +++++++---- codex-rs/core/src/models_manager/manager.rs | 1 + codex-rs/core/src/transport_manager.rs | 19 ++++--------- .../core/tests/chat_completions_payload.rs | 1 + codex-rs/core/tests/chat_completions_sse.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 3 +- codex-rs/core/tests/responses_headers.rs | 3 ++ codex-rs/core/tests/suite/client.rs | 3 ++ .../core/tests/suite/client_websockets.rs | 5 +++- .../suite/stream_error_allows_next_turn.rs | 1 + .../core/tests/suite/stream_no_completed.rs | 1 + .../core/tests/suite/websocket_fallback.rs | 8 ++++-- 15 files changed, 68 insertions(+), 46 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 16c0c8945c..fe27f7e24e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -443,6 +443,11 @@ "minimum": 0.0, "type": "integer" }, + "supports_websockets": { + "default": false, + "description": "Whether this provider supports the Responses API WebSocket transport.", + "type": "boolean" + }, "wire_api": { "allOf": [ { @@ -1081,13 +1086,6 @@ ], "type": "string" }, - { - "description": "Experimental: Responses API over WebSocket transport.", - "enum": [ - "responses_websocket" - ], - "type": "string" - }, { "description": "Regular Chat Completions compatible with `/v1/chat/completions`.", "enum": [ diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 98fce871fd..4cb37a46d4 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -260,12 +260,18 @@ impl ModelClientSession { /// For Chat providers, the underlying stream is optionally aggregated /// based on the `show_raw_agent_reasoning` flag in the config. pub async fn stream(&mut self, prompt: &Prompt) -> Result { - let wire_api = self - .transport_manager - .effective_wire_api(self.state.provider.wire_api); + let wire_api = self.state.provider.wire_api; match wire_api { - WireApi::Responses => self.stream_responses_api(prompt).await, - WireApi::ResponsesWebsocket => self.stream_responses_websocket(prompt).await, + WireApi::Responses => { + let websocket_enabled = self.responses_websocket_enabled() + && !self.transport_manager.disable_websockets(); + + if websocket_enabled { + self.stream_responses_websocket(prompt).await + } else { + self.stream_responses_api(prompt).await + } + } WireApi::Chat => { let api_stream = self.stream_chat_completions(prompt).await?; @@ -285,9 +291,10 @@ impl ModelClientSession { } pub(crate) fn try_switch_fallback_transport(&mut self) -> bool { + let websocket_enabled = self.responses_websocket_enabled(); let activated = self .transport_manager - .activate_http_fallback(self.state.provider.wire_api); + .activate_http_fallback(websocket_enabled); if activated { warn!("falling back to HTTP"); self.state.otel_manager.counter( @@ -302,6 +309,15 @@ impl ModelClientSession { activated } + fn responses_websocket_enabled(&self) -> bool { + self.state.provider.supports_websockets + && self + .state + .config + .features + .enabled(Feature::ResponsesWebsockets) + } + fn build_responses_request(&self, prompt: &Prompt) -> Result { let instructions = prompt.base_instructions.text.clone(); let tools_json: Vec = create_tools_json_for_responses_api(&prompt.tools)?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 895e54c040..bc7dba6d71 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1372,12 +1372,6 @@ impl Config { || cfg.sandbox_mode.is_some(); let mut model_providers = built_in_model_providers(); - if features.enabled(Feature::ResponsesWebsockets) - && let Some(provider) = model_providers.get_mut("openai") - && provider.is_openai() - { - provider.wire_api = crate::model_provider_info::WireApi::ResponsesWebsocket; - } // Merge user-defined providers into the built-in list. for (key, provider) in cfg.model_providers.into_iter() { model_providers.entry(key).or_insert(provider); @@ -2555,7 +2549,7 @@ profile = "project" } #[test] - fn responses_websockets_feature_updates_openai_provider() -> std::io::Result<()> { + fn responses_websockets_feature_does_not_change_wire_api() -> std::io::Result<()> { let codex_home = TempDir::new()?; let mut entries = BTreeMap::new(); entries.insert("responses_websockets".to_string(), true); @@ -2572,7 +2566,7 @@ profile = "project" assert_eq!( config.model_provider.wire_api, - crate::model_provider_info::WireApi::ResponsesWebsocket + crate::model_provider_info::WireApi::Responses ); Ok(()) @@ -3692,6 +3686,7 @@ model_verbosity = "high" stream_max_retries: Some(10), stream_idle_timeout_ms: Some(300_000), requires_openai_auth: false, + supports_websockets: false, }; let model_provider_map = { let mut model_provider_map = built_in_model_providers(); diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index b1422458ca..4f69f02d91 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -43,10 +43,6 @@ pub enum WireApi { /// The Responses API exposed by OpenAI at `/v1/responses`. Responses, - /// Experimental: Responses API over WebSocket transport. - #[serde(rename = "responses_websocket")] - ResponsesWebsocket, - /// Regular Chat Completions compatible with `/v1/chat/completions`. #[default] Chat, @@ -105,6 +101,10 @@ pub struct ModelProviderInfo { /// and API key (if needed) comes from the "env_key" environment variable. #[serde(default)] pub requires_openai_auth: bool, + + /// Whether this provider supports the Responses API WebSocket transport. + #[serde(default)] + pub supports_websockets: bool, } impl ModelProviderInfo { @@ -162,7 +162,6 @@ impl ModelProviderInfo { query_params: self.query_params.clone(), wire: match self.wire_api { WireApi::Responses => ApiWireApi::Responses, - WireApi::ResponsesWebsocket => ApiWireApi::Responses, WireApi::Chat => ApiWireApi::Chat, }, headers, @@ -254,6 +253,7 @@ impl ModelProviderInfo { stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: true, + supports_websockets: true, } } @@ -332,6 +332,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + supports_websockets: false, } } @@ -360,6 +361,7 @@ base_url = "http://localhost:11434/v1" stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + supports_websockets: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -390,6 +392,7 @@ query_params = { api-version = "2025-04-01-preview" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + supports_websockets: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -423,6 +426,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + supports_websockets: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -454,6 +458,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + supports_websockets: false, }; let api = provider.to_api_provider(None).expect("api provider"); assert!( @@ -476,6 +481,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + supports_websockets: false, }; let named_api = named_provider.to_api_provider(None).expect("api provider"); assert!(named_api.is_azure_responses_endpoint()); @@ -500,6 +506,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + supports_websockets: false, }; let api = provider.to_api_provider(None).expect("api provider"); assert!( diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 58049b4747..77fb44cd5d 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -432,6 +432,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + supports_websockets: false, } } diff --git a/codex-rs/core/src/transport_manager.rs b/codex-rs/core/src/transport_manager.rs index f1fe68faf4..2b5d095000 100644 --- a/codex-rs/core/src/transport_manager.rs +++ b/codex-rs/core/src/transport_manager.rs @@ -2,11 +2,9 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use crate::model_provider_info::WireApi; - #[derive(Clone, Debug, Default)] pub struct TransportManager { - fallback_to_http: Arc, + disable_websockets: Arc, } impl TransportManager { @@ -14,18 +12,11 @@ impl TransportManager { Self::default() } - pub fn effective_wire_api(&self, provider_wire_api: WireApi) -> WireApi { - if self.fallback_to_http.load(Ordering::Relaxed) - && provider_wire_api == WireApi::ResponsesWebsocket - { - WireApi::Responses - } else { - provider_wire_api - } + pub fn disable_websockets(&self) -> bool { + self.disable_websockets.load(Ordering::Relaxed) } - pub fn activate_http_fallback(&self, provider_wire_api: WireApi) -> bool { - provider_wire_api == WireApi::ResponsesWebsocket - && !self.fallback_to_http.swap(true, Ordering::Relaxed) + pub fn activate_http_fallback(&self, websocket_enabled: bool) -> bool { + websocket_enabled && !self.disable_websockets.swap(true, Ordering::Relaxed) } } diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index 5e8c895a3f..ffa2caa59f 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -60,6 +60,7 @@ async fn run_request(input: Vec) -> Value { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + supports_websockets: false, }; let codex_home = match TempDir::new() { diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 84d0ede085..160699733e 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -59,6 +59,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + supports_websockets: false, }; let codex_home = match TempDir::new() { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a247453775..af85ebb955 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -8,7 +8,6 @@ use codex_core::CodexAuth; use codex_core::CodexThread; use codex_core::ModelProviderInfo; use codex_core::ThreadManager; -use codex_core::WireApi; use codex_core::built_in_model_providers; use codex_core::config::Config; use codex_core::features::Feature; @@ -127,7 +126,7 @@ impl TestCodexBuilder { let base_url_clone = base_url.clone(); self.config_mutators.push(Box::new(move |config| { config.model_provider.base_url = Some(base_url_clone); - config.model_provider.wire_api = WireApi::ResponsesWebsocket; + config.features.enable(Feature::ResponsesWebsockets); })); self.build_with_home_and_base_url(base_url, home, None) .await diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index b3c0165f46..b2c64818cc 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -57,6 +57,7 @@ async fn responses_stream_includes_subagent_header_on_review() { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + supports_websockets: false, }; let codex_home = TempDir::new().expect("failed to create TempDir"); @@ -154,6 +155,7 @@ async fn responses_stream_includes_subagent_header_on_other() { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + supports_websockets: false, }; let codex_home = TempDir::new().expect("failed to create TempDir"); @@ -307,6 +309,7 @@ async fn responses_respects_model_info_overrides_from_config() { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + supports_websockets: false, }; let codex_home = TempDir::new().expect("failed to create TempDir"); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 8b8adf06ab..72608a44d8 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1151,6 +1151,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + supports_websockets: false, }; let codex_home = TempDir::new().unwrap(); @@ -1671,6 +1672,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + supports_websockets: false, }; // Init session @@ -1751,6 +1753,7 @@ async fn env_var_overrides_loaded_auth() { stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + supports_websockets: false, }; // Init session diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 60229e01d6..5037479987 100644 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -10,6 +10,7 @@ use codex_core::ResponseEvent; use codex_core::ResponseItem; use codex_core::TransportManager; use codex_core::WireApi; +use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::SessionSource; use codex_otel::OtelManager; @@ -188,7 +189,7 @@ fn websocket_provider(server: &WebSocketTestServer) -> ModelProviderInfo { env_key: None, env_key_instructions: None, experimental_bearer_token: None, - wire_api: WireApi::ResponsesWebsocket, + wire_api: WireApi::Responses, query_params: None, http_headers: None, env_http_headers: None, @@ -196,6 +197,7 @@ fn websocket_provider(server: &WebSocketTestServer) -> ModelProviderInfo { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + supports_websockets: true, } } @@ -204,6 +206,7 @@ async fn websocket_harness(server: &WebSocketTestServer) -> WebsocketTestHarness let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home).await; config.model = Some(MODEL.to_string()); + config.features.enable(Feature::ResponsesWebsockets); let config = Arc::new(config); let model_info = ModelsManager::construct_model_info_offline(MODEL, &config); let conversation_id = ThreadId::new(); diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index 6bfcef38b3..ec18816235 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -73,6 +73,7 @@ async fn continue_after_stream_error() { stream_max_retries: Some(1), stream_idle_timeout_ms: Some(2_000), requires_openai_auth: false, + supports_websockets: false, }; let TestCodex { codex, .. } = test_codex() diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index a4962d497e..68b7763eca 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -81,6 +81,7 @@ async fn retries_on_early_close() { stream_max_retries: Some(1), stream_idle_timeout_ms: Some(2000), requires_openai_auth: false, + supports_websockets: false, }; let TestCodex { codex, .. } = test_codex() diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index d52236a8a2..e61c4e5c2d 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use codex_core::WireApi; +use codex_core::features::Feature; use core_test_support::responses; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; @@ -26,7 +26,8 @@ async fn websocket_fallback_switches_to_http_after_retries_exhausted() -> Result let base_url = format!("{}/v1", server.uri()); move |config| { config.model_provider.base_url = Some(base_url); - config.model_provider.wire_api = WireApi::ResponsesWebsocket; + config.model_provider.wire_api = codex_core::WireApi::Responses; + config.features.enable(Feature::ResponsesWebsockets); config.model_provider.stream_max_retries = Some(0); config.model_provider.request_max_retries = Some(0); } @@ -70,7 +71,8 @@ async fn websocket_fallback_is_sticky_across_turns() -> Result<()> { let base_url = format!("{}/v1", server.uri()); move |config| { config.model_provider.base_url = Some(base_url); - config.model_provider.wire_api = WireApi::ResponsesWebsocket; + config.model_provider.wire_api = codex_core::WireApi::Responses; + config.features.enable(Feature::ResponsesWebsockets); config.model_provider.stream_max_retries = Some(0); config.model_provider.request_max_retries = Some(0); } From ca9d417633ad47fc5ed8dbc5ab01d57bd9ea70c0 Mon Sep 17 00:00:00 2001 From: natea-oai Date: Thu, 29 Jan 2026 14:41:42 -0800 Subject: [PATCH 35/70] updating comment to better indicate intent of skipping `quit` in the main slash command menu (#10186) Updates comment indicating intent for skipping `quit` in the main slash command dropdown. --- codex-rs/tui/src/bottom_pane/command_popup.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 5423231db8..621683923d 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -122,7 +122,8 @@ impl CommandPopup { if filter.is_empty() { // Built-ins first, in presentation order. for (_, cmd) in self.builtins.iter() { - // Skipping quit as it's a duplicate of exit. + // Hide alias commands in the default popup list so each unique action appears once. + // `quit` is an alias of `exit`, so we skip `quit` here. if *cmd == SlashCommand::Quit { continue; } From b79bf69af6a68d162a95af883564d9e480948e62 Mon Sep 17 00:00:00 2001 From: Colin Young Date: Thu, 29 Jan 2026 14:59:07 -0800 Subject: [PATCH 36/70] [Codex][CLI] Show model-capacity guidance on 429 (#10118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ###### Problem Users get generic 429s with no guidance when a model is at capacity. ###### Solution Detect model-cap headers, surface a clear “try a different model” message, and keep behavior non‑intrusive (no auto‑switch). ###### Scope CLI/TUI only; protocol + error mapping updated to carry model‑cap info. ###### Tests - just fmt - cargo test -p codex-tui - cargo test -p codex-core --lib shell_snapshot::tests::try_new_creates_and_deletes_snapshot_file -- --nocapture (ran in isolated env) - validate local build with backend image --- .../app-server-protocol/src/protocol/v2.rs | 11 +++ codex-rs/core/src/api_bridge.rs | 54 ++++++++++++ codex-rs/core/src/error.rs | 85 ++++++++++++++++++- codex-rs/protocol/src/protocol.rs | 4 + codex-rs/tui/src/chatwidget.rs | 79 ++++++++++++++++- codex-rs/tui/src/chatwidget/tests.rs | 37 ++++++++ 6 files changed, 268 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 247360282c..78cce916e4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -86,6 +86,10 @@ macro_rules! v2_enum_from_core { pub enum CodexErrorInfo { ContextWindowExceeded, UsageLimitExceeded, + ModelCap { + model: String, + reset_after_seconds: Option, + }, HttpConnectionFailed { #[serde(rename = "httpStatusCode")] #[ts(rename = "httpStatusCode")] @@ -122,6 +126,13 @@ impl From for CodexErrorInfo { match value { CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, + CoreCodexErrorInfo::ModelCap { + model, + reset_after_seconds, + } => CodexErrorInfo::ModelCap { + model, + reset_after_seconds, + }, CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { CodexErrorInfo::HttpConnectionFailed { http_status_code } } diff --git a/codex-rs/core/src/api_bridge.rs b/codex-rs/core/src/api_bridge.rs index 79ca839816..ec21f1ec85 100644 --- a/codex-rs/core/src/api_bridge.rs +++ b/codex-rs/core/src/api_bridge.rs @@ -9,6 +9,7 @@ use serde::Deserialize; use crate::auth::CodexAuth; use crate::error::CodexErr; +use crate::error::ModelCapError; use crate::error::RetryLimitReachedError; use crate::error::UnexpectedResponseError; use crate::error::UsageLimitReachedError; @@ -49,6 +50,23 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { } else if status == http::StatusCode::INTERNAL_SERVER_ERROR { CodexErr::InternalServerError } else if status == http::StatusCode::TOO_MANY_REQUESTS { + if let Some(model) = headers + .as_ref() + .and_then(|map| map.get(MODEL_CAP_MODEL_HEADER)) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + { + let reset_after_seconds = headers + .as_ref() + .and_then(|map| map.get(MODEL_CAP_RESET_AFTER_HEADER)) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + return CodexErr::ModelCap(ModelCapError { + model, + reset_after_seconds, + }); + } + if let Ok(err) = serde_json::from_str::(&body_text) { if err.error.error_type.as_deref() == Some("usage_limit_reached") { let rate_limits = headers.as_ref().and_then(parse_rate_limit); @@ -92,6 +110,42 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { } } +const MODEL_CAP_MODEL_HEADER: &str = "x-codex-model-cap-model"; +const MODEL_CAP_RESET_AFTER_HEADER: &str = "x-codex-model-cap-reset-after-seconds"; + +#[cfg(test)] +mod tests { + use super::*; + use codex_api::TransportError; + use http::HeaderMap; + use http::StatusCode; + + #[test] + fn map_api_error_maps_model_cap_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + MODEL_CAP_MODEL_HEADER, + http::HeaderValue::from_static("boomslang"), + ); + headers.insert( + MODEL_CAP_RESET_AFTER_HEADER, + http::HeaderValue::from_static("120"), + ); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: StatusCode::TOO_MANY_REQUESTS, + url: Some("http://example.com/v1/responses".to_string()), + headers: Some(headers), + body: Some(String::new()), + })); + + let CodexErr::ModelCap(model_cap) = err else { + panic!("expected CodexErr::ModelCap, got {err:?}"); + }; + assert_eq!(model_cap.model, "boomslang"); + assert_eq!(model_cap.reset_after_seconds, Some(120)); + } +} + fn extract_request_id(headers: Option<&HeaderMap>) -> Option { headers.and_then(|map| { ["cf-ray", "x-request-id", "x-oai-request-id"] diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index e9830f5189..6c284074c9 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -113,6 +113,9 @@ pub enum CodexErr { #[error("{0}")] UsageLimitReached(UsageLimitReachedError), + #[error("{0}")] + ModelCap(ModelCapError), + #[error("{0}")] ResponseStreamFailed(ResponseStreamFailed), @@ -205,7 +208,8 @@ impl CodexErr { | CodexErr::AgentLimitReached { .. } | CodexErr::Spawn | CodexErr::SessionConfiguredNotFirstEvent - | CodexErr::UsageLimitReached(_) => false, + | CodexErr::UsageLimitReached(_) + | CodexErr::ModelCap(_) => false, CodexErr::Stream(..) | CodexErr::Timeout | CodexErr::UnexpectedStatus(_) @@ -394,6 +398,30 @@ impl std::fmt::Display for UsageLimitReachedError { } } +#[derive(Debug)] +pub struct ModelCapError { + pub(crate) model: String, + pub(crate) reset_after_seconds: Option, +} + +impl std::fmt::Display for ModelCapError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut message = format!( + "Model {} is at capacity. Please try a different model.", + self.model + ); + if let Some(seconds) = self.reset_after_seconds { + message.push_str(&format!( + " Try again in {}.", + format_duration_short(seconds) + )); + } else { + message.push_str(" Try again later."); + } + write!(f, "{message}") + } +} + fn retry_suffix(resets_at: Option<&DateTime>) -> String { if let Some(resets_at) = resets_at { let formatted = format_retry_timestamp(resets_at); @@ -425,6 +453,18 @@ fn format_retry_timestamp(resets_at: &DateTime) -> String { } } +fn format_duration_short(seconds: u64) -> String { + if seconds < 60 { + "less than a minute".to_string() + } else if seconds < 3600 { + format!("{}m", seconds / 60) + } else if seconds < 86_400 { + format!("{}h", seconds / 3600) + } else { + format!("{}d", seconds / 86_400) + } +} + fn day_suffix(day: u32) -> &'static str { match day { 11..=13 => "th", @@ -488,6 +528,10 @@ impl CodexErr { CodexErr::UsageLimitReached(_) | CodexErr::QuotaExceeded | CodexErr::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded, + CodexErr::ModelCap(err) => CodexErrorInfo::ModelCap { + model: err.model.clone(), + reset_after_seconds: err.reset_after_seconds, + }, CodexErr::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code: self.http_status_code_value(), }, @@ -631,6 +675,45 @@ mod tests { ); } + #[test] + fn model_cap_error_formats_message() { + let err = ModelCapError { + model: "boomslang".to_string(), + reset_after_seconds: Some(120), + }; + assert_eq!( + err.to_string(), + "Model boomslang is at capacity. Please try a different model. Try again in 2m." + ); + } + + #[test] + fn model_cap_error_formats_message_without_reset() { + let err = ModelCapError { + model: "boomslang".to_string(), + reset_after_seconds: None, + }; + assert_eq!( + err.to_string(), + "Model boomslang is at capacity. Please try a different model. Try again later." + ); + } + + #[test] + fn model_cap_error_maps_to_protocol() { + let err = CodexErr::ModelCap(ModelCapError { + model: "boomslang".to_string(), + reset_after_seconds: Some(30), + }); + assert_eq!( + err.to_codex_protocol_error(), + CodexErrorInfo::ModelCap { + model: "boomslang".to_string(), + reset_after_seconds: Some(30), + } + ); + } + #[test] fn sandbox_denied_uses_aggregated_output_when_stderr_empty() { let output = ExecToolCallOutput { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b9a4f2120b..aea7d3f013 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -925,6 +925,10 @@ pub enum AgentStatus { pub enum CodexErrorInfo { ContextWindowExceeded, UsageLimitExceeded, + ModelCap { + model: String, + reset_after_seconds: Option, + }, HttpConnectionFailed { http_status_code: Option, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8a0dd81afe..c132130515 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -49,6 +49,7 @@ use codex_core::protocol::AgentReasoningRawContentDeltaEvent; use codex_core::protocol::AgentReasoningRawContentEvent; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::BackgroundEventEvent; +use codex_core::protocol::CodexErrorInfo; use codex_core::protocol::CreditsSnapshot; use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::ErrorEvent; @@ -399,6 +400,33 @@ enum ConnectorsCacheState { Failed(String), } +#[derive(Debug)] +enum RateLimitErrorKind { + ModelCap { + model: String, + reset_after_seconds: Option, + }, + UsageLimit, + Generic, +} + +fn rate_limit_error_kind(info: &CodexErrorInfo) -> Option { + match info { + CodexErrorInfo::ModelCap { + model, + reset_after_seconds, + } => Some(RateLimitErrorKind::ModelCap { + model: model.clone(), + reset_after_seconds: *reset_after_seconds, + }), + CodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit), + CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(429), + } => Some(RateLimitErrorKind::Generic), + _ => None, + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExternalEditorState { #[default] @@ -1156,6 +1184,24 @@ impl ChatWidget { self.maybe_show_pending_rate_limit_prompt(); } + fn on_model_cap_error(&mut self, model: String, reset_after_seconds: Option) { + self.finalize_turn(); + + let mut message = format!("Model {model} is at capacity. Please try a different model."); + if let Some(seconds) = reset_after_seconds { + message.push_str(&format!( + " Try again in {}.", + format_duration_short(seconds) + )); + } else { + message.push_str(" Try again later."); + } + + self.add_to_history(history_cell::new_warning_event(message)); + self.request_redraw(); + self.maybe_send_next_queued_input(); + } + fn on_error(&mut self, message: String) { self.finalize_turn(); self.add_to_history(history_cell::new_error_event(message)); @@ -3085,7 +3131,26 @@ impl ChatWidget { self.on_rate_limit_snapshot(ev.rate_limits); } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), - EventMsg::Error(ErrorEvent { message, .. }) => self.on_error(message), + EventMsg::Error(ErrorEvent { + message, + codex_error_info, + }) => { + if let Some(info) = codex_error_info + && let Some(kind) = rate_limit_error_kind(&info) + { + match kind { + RateLimitErrorKind::ModelCap { + model, + reset_after_seconds, + } => self.on_model_cap_error(model, reset_after_seconds), + RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { + self.on_error(message) + } + } + } else { + self.on_error(message); + } + } EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev), EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev), EventMsg::TurnAborted(ev) => match ev.reason { @@ -5911,5 +5976,17 @@ pub(crate) fn show_review_commit_picker_with_entries( }); } +fn format_duration_short(seconds: u64) -> String { + if seconds < 60 { + "less than a minute".to_string() + } else if seconds < 3600 { + format!("{}m", seconds / 60) + } else if seconds < 86_400 { + format!("{}h", seconds / 3600) + } else { + format!("{}d", seconds / 86_400) + } +} + #[cfg(test)] pub(crate) mod tests; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 570a72750b..9859e9e56f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3021,6 +3021,43 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { ); } +#[tokio::test] +async fn model_cap_error_does_not_switch_models() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("boomslang")).await; + chat.set_model("boomslang"); + while rx.try_recv().is_ok() {} + while op_rx.try_recv().is_ok() {} + + chat.handle_codex_event(Event { + id: "err-1".to_string(), + msg: EventMsg::Error(ErrorEvent { + message: "model cap".to_string(), + codex_error_info: Some(CodexErrorInfo::ModelCap { + model: "boomslang".to_string(), + reset_after_seconds: Some(120), + }), + }), + }); + + while let Ok(event) = rx.try_recv() { + if let AppEvent::UpdateModel(model) = event { + assert_eq!( + model, "boomslang", + "did not expect model switch on model-cap error" + ); + } + } + + while let Ok(event) = op_rx.try_recv() { + if let Op::OverrideTurnContext { model, .. } = event { + assert!( + model.is_none(), + "did not expect OverrideTurnContext model update on model-cap error" + ); + } + } +} + #[tokio::test] async fn approvals_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; From 81a17bb2c1041ad1d89527911318386ce036afa9 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Thu, 29 Jan 2026 15:46:04 -0800 Subject: [PATCH 37/70] feat(app-server): support external auth mode (#10012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This enables a new use case where `codex app-server` is embedded into a parent application that will directly own the user's ChatGPT auth lifecycle, which means it owns the user’s auth tokens and refreshes it when necessary. The parent application would just want a way to pass in the auth tokens for codex to use directly. The idea is that we are introducing a new "auth mode" currently only exposed via app server: **`chatgptAuthTokens`** which consist of the `id_token` (stores account metadata) and `access_token` (the bearer token used directly for backend API calls). These auth tokens are only stored in-memory. This new mode is in addition to the existing `apiKey` and `chatgpt` auth modes. This PR reuses the shape of our existing app-server account APIs as much as possible: - Update `account/login/start` with a new `chatgptAuthTokens` variant, which will allow the client to pass in the tokens and have codex app-server use them directly. Upon success, the server emits `account/login/completed` and `account/updated` notifications. - A new server->client request called `account/chatgptAuthTokens/refresh` which the server can use whenever the access token previously passed in has expired and it needs a new one from the parent application. I leveraged the core 401 retry loop which typically triggers auth token refreshes automatically, but made it pluggable: - **chatgpt** mode refreshes internally, as usual. - **chatgptAuthTokens** mode calls the client via `account/chatgptAuthTokens/refresh`, the client responds with updated tokens, codex updates its in-memory auth, then retries. This RPC has a 10s timeout and handles JSON-RPC errors from the client. Also some additional things: - chatgpt logins are blocked while external auth is active (have to log out first. typically clients will pick one OR the other, not support both) - `account/logout` clears external auth in memory - Ensures that if `forced_chatgpt_workspace_id` is set via the user's config, we respect it in both: - `account/login/start` with `chatgptAuthTokens` (returns a JSON-RPC error back to the client) - `account/chatgptAuthTokens/refresh` (fails the turn, and on next request app-server will send another `account/chatgptAuthTokens/refresh` request to the client). --- codex-rs/Cargo.lock | 1 + .../src/protocol/common.rs | 61 +- .../app-server-protocol/src/protocol/v2.rs | 57 ++ codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/README.md | 9 +- .../app-server/src/codex_message_processor.rs | 126 +++- codex-rs/app-server/src/lib.rs | 2 +- codex-rs/app-server/src/message_processor.rs | 72 +- codex-rs/app-server/src/outgoing_message.rs | 34 +- .../app-server/tests/common/auth_fixtures.rs | 2 + .../app-server/tests/common/mcp_process.rs | 25 + codex-rs/app-server/tests/suite/v2/account.rs | 707 +++++++++++++++++- codex-rs/cli/src/login.rs | 4 + codex-rs/core/config.schema.json | 7 + codex-rs/core/src/auth.rs | 400 ++++++++-- codex-rs/core/src/auth/storage.rs | 89 ++- codex-rs/core/src/client.rs | 4 +- codex-rs/core/src/model_provider_info.rs | 5 +- codex-rs/core/src/models_manager/manager.rs | 8 +- codex-rs/core/src/token_data.rs | 15 +- codex-rs/core/tests/suite/auth_refresh.rs | 11 + codex-rs/login/src/server.rs | 2 + codex-rs/tui/src/chatwidget.rs | 7 +- codex-rs/tui/src/onboarding/auth.rs | 5 +- codex-rs/tui/src/status/helpers.rs | 2 +- 25 files changed, 1577 insertions(+), 79 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4c321b3f62..35d3129106 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1080,6 +1080,7 @@ version = "0.0.0" dependencies = [ "anyhow", "app_test_support", + "async-trait", "axum", "base64", "chrono", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index cad6976b4b..2aaf419440 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -28,6 +28,11 @@ impl GitSha { pub enum AuthMode { ApiKey, ChatGPT, + /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. + #[serde(rename = "chatgptAuthTokens")] + #[ts(rename = "chatgptAuthTokens")] + #[strum(serialize = "chatgptAuthTokens")] + ChatgptAuthTokens, } /// Generates an `enum ClientRequest` where each variant is a request that the @@ -534,6 +539,11 @@ server_request_definitions! { response: v2::DynamicToolCallResponse, }, + ChatgptAuthTokensRefresh => "account/chatgptAuthTokens/refresh" { + params: v2::ChatgptAuthTokensRefreshParams, + response: v2::ChatgptAuthTokensRefreshResponse, + }, + /// DEPRECATED APIs below /// Request to approve a patch. /// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage). @@ -753,6 +763,29 @@ mod tests { Ok(()) } + #[test] + fn serialize_chatgpt_auth_tokens_refresh_request() -> Result<()> { + let request = ServerRequest::ChatgptAuthTokensRefresh { + request_id: RequestId::Integer(8), + params: v2::ChatgptAuthTokensRefreshParams { + reason: v2::ChatgptAuthTokensRefreshReason::Unauthorized, + previous_account_id: Some("org-123".to_string()), + }, + }; + assert_eq!( + json!({ + "method": "account/chatgptAuthTokens/refresh", + "id": 8, + "params": { + "reason": "unauthorized", + "previousAccountId": "org-123" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_get_account_rate_limits() -> Result<()> { let request = ClientRequest::GetAccountRateLimits { @@ -842,10 +875,34 @@ mod tests { Ok(()) } + #[test] + fn serialize_account_login_chatgpt_auth_tokens() -> Result<()> { + let request = ClientRequest::LoginAccount { + request_id: RequestId::Integer(5), + params: v2::LoginAccountParams::ChatgptAuthTokens { + access_token: "access-token".to_string(), + id_token: "id-token".to_string(), + }, + }; + assert_eq!( + json!({ + "method": "account/login/start", + "id": 5, + "params": { + "type": "chatgptAuthTokens", + "accessToken": "access-token", + "idToken": "id-token" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_get_account() -> Result<()> { let request = ClientRequest::GetAccount { - request_id: RequestId::Integer(5), + request_id: RequestId::Integer(6), params: v2::GetAccountParams { refresh_token: false, }, @@ -853,7 +910,7 @@ mod tests { assert_eq!( json!({ "method": "account/read", - "id": 5, + "id": 6, "params": { "refreshToken": false } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 78cce916e4..ac3df37bd0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -835,6 +835,24 @@ pub enum LoginAccountParams { #[serde(rename = "chatgpt")] #[ts(rename = "chatgpt")] Chatgpt, + /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. + /// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have. + #[serde(rename = "chatgptAuthTokens")] + #[ts(rename = "chatgptAuthTokens")] + ChatgptAuthTokens { + /// ID token (JWT) supplied by the client. + /// + /// This token is used for identity and account metadata (email, plan type, + /// workspace id). + #[serde(rename = "idToken")] + #[ts(rename = "idToken")] + id_token: String, + /// Access token (JWT) supplied by the client. + /// This token is used for backend API requests. + #[serde(rename = "accessToken")] + #[ts(rename = "accessToken")] + access_token: String, + }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -854,6 +872,9 @@ pub enum LoginAccountResponse { /// URL the client should open in a browser to initiate the OAuth flow. auth_url: String, }, + #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] + #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] + ChatgptAuthTokens {}, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -884,6 +905,37 @@ pub struct CancelLoginAccountResponse { #[ts(export_to = "v2/")] pub struct LogoutAccountResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ChatgptAuthTokensRefreshReason { + /// Codex attempted a backend request and received `401 Unauthorized`. + Unauthorized, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ChatgptAuthTokensRefreshParams { + pub reason: ChatgptAuthTokensRefreshReason, + /// Workspace/account identifier that Codex was previously using. + /// + /// Clients that manage multiple accounts/workspaces can use this as a hint + /// to refresh the token for the correct workspace. + /// + /// This may be `null` when the prior ID token did not include a workspace + /// identifier (`chatgpt_account_id`) or when the token could not be parsed. + pub previous_account_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ChatgptAuthTokensRefreshResponse { + pub id_token: String, + pub access_token: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -895,6 +947,11 @@ pub struct GetAccountRateLimitsResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct GetAccountParams { + /// When `true`, requests a proactive token refresh before returning. + /// + /// In managed auth mode this triggers the normal refresh-token flow. In + /// external auth mode this flag is ignored. Clients should refresh tokens + /// themselves and call `account/login/start` with `chatgptAuthTokens`. #[serde(default)] pub refresh_token: bool, } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index af2f30c49c..820edf59c2 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -17,6 +17,7 @@ workspace = true [dependencies] anyhow = { workspace = true } +async-trait = { workspace = true } codex-arg0 = { workspace = true } codex-common = { workspace = true, features = ["cli"] } codex-core = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index caba8580aa..e9ebcd9661 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -659,10 +659,17 @@ $demo-app Pull the latest updates from the team. The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. +### Authentication modes + +Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`) and can be inferred from `account/read`. + +- **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests. +- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"`; Codex persists tokens to disk and refreshes them automatically. + ### API Overview - `account/read` — fetch current account info; optionally refresh tokens. -- `account/login/start` — begin login (`apiKey` or `chatgpt`). +- `account/login/start` — begin login (`apiKey`, `chatgpt`). - `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). - `account/login/cancel` — cancel a pending ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated`. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e1c982a99d..660b4b87d0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -57,6 +57,7 @@ use codex_app_server_protocol::ListConversationsResponse; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::LoginAccountParams; +use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LoginApiKeyResponse; use codex_app_server_protocol::LoginChatGptCompleteNotification; @@ -141,6 +142,7 @@ use codex_core::ThreadManager; use codex_core::ThreadSortKey as CoreThreadSortKey; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; +use codex_core::auth::login_with_chatgpt_auth_tokens; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigService; @@ -169,6 +171,7 @@ use codex_core::read_session_meta_line; use codex_core::rollout_date_parts; use codex_core::sandboxing::SandboxPermissions; use codex_core::state_db::get_state_db; +use codex_core::token_data::parse_id_token; use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_feedback::CodexFeedback; use codex_login::ServerOptions as LoginServerOptions; @@ -607,6 +610,22 @@ impl CodexMessageProcessor { LoginAccountParams::Chatgpt => { self.login_chatgpt_v2(request_id).await; } + LoginAccountParams::ChatgptAuthTokens { + id_token, + access_token, + } => { + self.login_chatgpt_auth_tokens(request_id, id_token, access_token) + .await; + } + } + } + + fn external_auth_active_error(&self) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it." + .to_string(), + data: None, } } @@ -614,6 +633,10 @@ impl CodexMessageProcessor { &mut self, params: &LoginApiKeyParams, ) -> std::result::Result<(), JSONRPCErrorError> { + if self.auth_manager.is_external_auth_active() { + return Err(self.external_auth_active_error()); + } + if matches!( self.config.forced_login_method, Some(ForcedLoginMethod::Chatgpt) @@ -706,6 +729,10 @@ impl CodexMessageProcessor { ) -> std::result::Result { let config = self.config.as_ref(); + if self.auth_manager.is_external_auth_active() { + return Err(self.external_auth_active_error()); + } + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -964,6 +991,98 @@ impl CodexMessageProcessor { } } + async fn login_chatgpt_auth_tokens( + &mut self, + request_id: RequestId, + id_token: String, + access_token: String, + ) { + if matches!( + self.config.forced_login_method, + Some(ForcedLoginMethod::Api) + ) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "External ChatGPT auth is disabled. Use API key login instead." + .to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + // Cancel any active login attempt to avoid persisting managed auth state. + { + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + drop(active); + } + } + + let id_token_info = match parse_id_token(&id_token) { + Ok(info) => info, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid id token: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref() + && id_token_info.chatgpt_account_id.as_deref() != Some(expected_workspace) + { + let account_id = id_token_info.chatgpt_account_id; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "External auth must use workspace {expected_workspace}, but received {account_id:?}." + ), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + if let Err(err) = + login_with_chatgpt_auth_tokens(&self.config.codex_home, &id_token, &access_token) + { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to set external auth: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + self.auth_manager.reload(); + + self.outgoing + .send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {}) + .await; + + let payload_login_completed = AccountLoginCompletedNotification { + login_id: None, + success: true, + error: None, + }; + self.outgoing + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_login_completed, + )) + .await; + + let payload_v2 = AccountUpdatedNotification { + auth_mode: self.auth_manager.get_auth_mode(), + }; + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .await; + } + async fn logout_common(&mut self) -> std::result::Result, JSONRPCErrorError> { // Cancel any active login attempt. { @@ -1026,6 +1145,9 @@ impl CodexMessageProcessor { } async fn refresh_token_if_requested(&self, do_refresh: bool) { + if self.auth_manager.is_external_auth_active() { + return; + } if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { tracing::warn!("failed to refresh token while getting account: {err}"); } @@ -1100,7 +1222,7 @@ impl CodexMessageProcessor { let account = match self.auth_manager.auth_cached() { Some(auth) => Some(match auth.mode { AuthMode::ApiKey => Account::ApiKey {}, - AuthMode::ChatGPT => { + AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => { let email = auth.get_account_email(); let plan_type = auth.account_plan_type(); @@ -1159,7 +1281,7 @@ impl CodexMessageProcessor { }); }; - if auth.mode != AuthMode::ChatGPT { + if !matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) { return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: "chatgpt authentication required to read rate limits".to_string(), diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index ca588e59d4..5b3d39704c 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -312,7 +312,7 @@ pub async fn run_main( JSONRPCMessage::Request(r) => processor.process_request(r).await, JSONRPCMessage::Response(r) => processor.process_response(r).await, JSONRPCMessage::Notification(n) => processor.process_notification(n).await, - JSONRPCMessage::Error(e) => processor.process_error(e), + JSONRPCMessage::Error(e) => processor.process_error(e).await, } } created = thread_created_rx.recv(), if listen_for_threads => { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 95d62d3f90..ced93c3bdd 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -5,6 +5,10 @@ use crate::codex_message_processor::CodexMessageProcessor; use crate::config_api::ConfigApi; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::outgoing_message::OutgoingMessageSender; +use async_trait::async_trait; +use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; +use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; +use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -19,8 +23,13 @@ use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequestPayload; use codex_core::AuthManager; use codex_core::ThreadManager; +use codex_core::auth::ExternalAuthRefreshContext; +use codex_core::auth::ExternalAuthRefreshReason; +use codex_core::auth::ExternalAuthRefresher; +use codex_core::auth::ExternalAuthTokens; use codex_core::config::Config; use codex_core::config_loader::LoaderOverrides; use codex_core::default_client::SetOriginatorError; @@ -31,8 +40,64 @@ use codex_feedback::CodexFeedback; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use tokio::sync::broadcast; +use tokio::time::Duration; +use tokio::time::timeout; use toml::Value as TomlValue; +const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone)] +struct ExternalAuthRefreshBridge { + outgoing: Arc, +} + +impl ExternalAuthRefreshBridge { + fn map_reason(reason: ExternalAuthRefreshReason) -> ChatgptAuthTokensRefreshReason { + match reason { + ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized, + } + } +} + +#[async_trait] +impl ExternalAuthRefresher for ExternalAuthRefreshBridge { + async fn refresh( + &self, + context: ExternalAuthRefreshContext, + ) -> std::io::Result { + let params = ChatgptAuthTokensRefreshParams { + reason: Self::map_reason(context.reason), + previous_account_id: context.previous_account_id, + }; + + let (request_id, rx) = self + .outgoing + .send_request_with_id(ServerRequestPayload::ChatgptAuthTokensRefresh(params)) + .await; + + let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await { + Ok(result) => result.map_err(|err| { + std::io::Error::other(format!("auth refresh request canceled: {err}")) + })?, + Err(_) => { + let _canceled = self.outgoing.cancel_request(&request_id).await; + return Err(std::io::Error::other(format!( + "auth refresh request timed out after {}s", + EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs() + ))); + } + }; + + let response: ChatgptAuthTokensRefreshResponse = + serde_json::from_value(result).map_err(std::io::Error::other)?; + + Ok(ExternalAuthTokens { + access_token: response.access_token, + id_token: response.id_token, + }) + } +} + pub(crate) struct MessageProcessor { outgoing: Arc, codex_message_processor: CodexMessageProcessor, @@ -59,6 +124,10 @@ impl MessageProcessor { false, config.cli_auth_credentials_store_mode, ); + auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); + auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge { + outgoing: outgoing.clone(), + })); let thread_manager = Arc::new(ThreadManager::new( config.codex_home.clone(), auth_manager.clone(), @@ -236,8 +305,9 @@ impl MessageProcessor { } /// Handle an error object received from the peer. - pub(crate) fn process_error(&mut self, err: JSONRPCError) { + pub(crate) async fn process_error(&mut self, err: JSONRPCError) { tracing::error!("<- error: {:?}", err); + self.outgoing.notify_client_error(err.id, err.error).await; } async fn handle_config_read(&self, request_id: RequestId, params: ConfigReadParams) { diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 7b4a599b00..be89775d86 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -39,6 +39,14 @@ impl OutgoingMessageSender { &self, request: ServerRequestPayload, ) -> oneshot::Receiver { + let (_id, rx) = self.send_request_with_id(request).await; + rx + } + + pub(crate) async fn send_request_with_id( + &self, + request: ServerRequestPayload, + ) -> (RequestId, oneshot::Receiver) { let id = RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed)); let outgoing_message_id = id.clone(); let (tx_approve, rx_approve) = oneshot::channel(); @@ -54,7 +62,7 @@ impl OutgoingMessageSender { let mut request_id_to_callback = self.request_id_to_callback.lock().await; request_id_to_callback.remove(&outgoing_message_id); } - rx_approve + (outgoing_message_id, rx_approve) } pub(crate) async fn notify_client_response(&self, id: RequestId, result: Result) { @@ -75,6 +83,30 @@ impl OutgoingMessageSender { } } + pub(crate) async fn notify_client_error(&self, id: RequestId, error: JSONRPCErrorError) { + let entry = { + let mut request_id_to_callback = self.request_id_to_callback.lock().await; + request_id_to_callback.remove_entry(&id) + }; + + match entry { + Some((id, _sender)) => { + warn!("client responded with error for {id:?}: {error:?}"); + } + None => { + warn!("could not find callback for {id:?}"); + } + } + } + + pub(crate) async fn cancel_request(&self, id: &RequestId) -> bool { + let entry = { + let mut request_id_to_callback = self.request_id_to_callback.lock().await; + request_id_to_callback.remove_entry(id) + }; + entry.is_some() + } + pub(crate) async fn send_response(&self, id: RequestId, response: T) { match serde_json::to_value(response) { Ok(result) => { diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 9f1b62744a..b78d5b105d 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -6,6 +6,7 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::DateTime; 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; @@ -158,6 +159,7 @@ pub fn write_chatgpt_auth( let last_refresh = fixture.last_refresh.unwrap_or_else(|| Some(Utc::now())); let auth = AuthDotJson { + auth_mode: Some(AuthMode::ChatGPT), openai_api_key: None, tokens: Some(tokens), last_refresh, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index fa160b4c41..4eeab8f950 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -29,11 +29,13 @@ use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::ListConversationsParams; +use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::NewConversationParams; @@ -298,6 +300,20 @@ impl McpProcess { self.send_request("account/read", params).await } + /// Send an `account/login/start` JSON-RPC request with ChatGPT auth tokens. + pub async fn send_chatgpt_auth_tokens_login_request( + &mut self, + id_token: String, + access_token: String, + ) -> anyhow::Result { + let params = LoginAccountParams::ChatgptAuthTokens { + id_token, + access_token, + }; + let params = Some(serde_json::to_value(params)?); + self.send_request("account/login/start", params).await + } + /// Send a `feedback/upload` JSON-RPC request. pub async fn send_feedback_upload_request( &mut self, @@ -608,6 +624,15 @@ impl McpProcess { .await } + pub async fn send_error( + &mut self, + id: RequestId, + error: JSONRPCErrorError, + ) -> anyhow::Result<()> { + self.send_jsonrpc_message(JSONRPCMessage::Error(JSONRPCError { id, error })) + .await + } + pub async fn send_notification( &mut self, notification: ClientNotification, diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index cbbdad84c1..d3145345e0 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -4,28 +4,43 @@ use app_test_support::McpProcess; use app_test_support::to_response; use app_test_support::ChatGptAuthFixture; +use app_test_support::ChatGptIdTokenClaims; +use app_test_support::encode_id_token; use app_test_support::write_chatgpt_auth; +use app_test_support::write_models_cache; use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; +use codex_app_server_protocol::CancelLoginAccountStatus; +use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; +use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStatus; use codex_core::auth::AuthCredentialsStoreMode; use codex_login::login_with_api_key; use codex_protocol::account::PlanType as AccountPlanType; +use core_test_support::responses; use pretty_assertions::assert_eq; +use serde_json::json; use serial_test::serial; use std::path::Path; use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::MockServer; +use wiremock::ResponseTemplate; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -35,10 +50,14 @@ struct CreateConfigTomlParams { forced_method: Option, forced_workspace_id: Option, requires_openai_auth: Option, + base_url: Option, } fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); + let base_url = params + .base_url + .unwrap_or_else(|| "http://127.0.0.1:0/v1".to_string()); let forced_line = if let Some(method) = params.forced_method { format!("forced_login_method = \"{method}\"\n") } else { @@ -66,7 +85,7 @@ model_provider = "mock_provider" [model_providers.mock_provider] name = "Mock provider for test" -base_url = "http://127.0.0.1:0/v1" +base_url = "{base_url}" wire_api = "responses" request_max_retries = 0 stream_max_retries = 0 @@ -133,6 +152,627 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> { Ok(()) } +#[tokio::test] +async fn set_auth_token_updates_account_and_notifies() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + let id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("embedded@example.com") + .plan_type("pro") + .chatgpt_account_id("org-embedded"), + )?; + let access_token = "access-embedded".to_string(); + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let set_id = mcp + .send_chatgpt_auth_tokens_login_request(id_token.clone(), access_token) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountUpdated(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.auth_mode, Some(AuthMode::ChatgptAuthTokens)); + + let get_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let get_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(get_id)), + ) + .await??; + let account: GetAccountResponse = to_response(get_resp)?; + assert_eq!( + account, + GetAccountResponse { + account: Some(Account::Chatgpt { + email: "embedded@example.com".to_string(), + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + } + ); + + Ok(()) +} + +#[tokio::test] +async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + let id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("embedded@example.com") + .plan_type("pro") + .chatgpt_account_id("org-embedded"), + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let set_id = mcp + .send_chatgpt_auth_tokens_login_request(id_token, "access-embedded".to_string()) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + let _updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + + let get_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: true, + }) + .await?; + let get_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(get_id)), + ) + .await??; + let account: GetAccountResponse = to_response(get_resp)?; + assert_eq!( + account, + GetAccountResponse { + account: Some(Account::Chatgpt { + email: "embedded@example.com".to_string(), + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + } + ); + + let refresh_request = timeout( + Duration::from_millis(250), + mcp.read_stream_until_request_message(), + ) + .await; + assert!( + refresh_request.is_err(), + "external mode should not emit account/chatgptAuthTokens/refresh for refreshToken=true" + ); + + Ok(()) +} + +async fn respond_to_refresh_request( + mcp: &mut McpProcess, + access_token: &str, + id_token: &str, +) -> Result<()> { + let refresh_req: ServerRequest = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = refresh_req else { + bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}"); + }; + assert_eq!(params.reason, ChatgptAuthTokensRefreshReason::Unauthorized); + let response = ChatgptAuthTokensRefreshResponse { + access_token: access_token.to_string(), + id_token: id_token.to_string(), + }; + mcp.send_response(request_id, serde_json::to_value(response)?) + .await?; + Ok(()) +} + +#[tokio::test] +// 401 response triggers account/chatgptAuthTokens/refresh and retries with new tokens. +async fn external_auth_refreshes_on_unauthorized() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + let success_sse = responses::sse(vec![ + responses::ev_response_created("resp-turn"), + responses::ev_assistant_message("msg-turn", "turn ok"), + responses::ev_completed("resp-turn"), + ]); + let unauthorized = ResponseTemplate::new(401).set_body_json(json!({ + "error": { "message": "unauthorized" } + })); + let responses_mock = responses::mount_response_sequence( + &mock_server, + vec![unauthorized, responses::sse_response(success_sse)], + ) + .await; + + let initial_id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("initial@example.com") + .plan_type("pro") + .chatgpt_account_id("org-initial"), + )?; + let refreshed_id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("refreshed@example.com") + .plan_type("pro") + .chatgpt_account_id("org-refreshed"), + )?; + let initial_access_token = "access-initial".to_string(); + let refreshed_access_token = "access-refreshed".to_string(); + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let set_id = mcp + .send_chatgpt_auth_tokens_login_request( + initial_id_token.clone(), + initial_access_token.clone(), + ) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + let _updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + + let thread_req = mcp + .send_thread_start_request(codex_app_server_protocol::ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let thread = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(codex_app_server_protocol::TurnStartParams { + thread_id: thread.thread.id, + input: vec![codex_app_server_protocol::UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + respond_to_refresh_request(&mut mcp, &refreshed_access_token, &refreshed_id_token).await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn_completed = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = responses_mock.requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].header("authorization"), + Some(format!("Bearer {initial_access_token}")) + ); + assert_eq!( + requests[1].header("authorization"), + Some(format!("Bearer {refreshed_access_token}")) + ); + + Ok(()) +} + +#[tokio::test] +// Client returns JSON-RPC error to refresh; turn fails. +async fn external_auth_refresh_error_fails_turn() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + let unauthorized = ResponseTemplate::new(401).set_body_json(json!({ + "error": { "message": "unauthorized" } + })); + let _responses_mock = + responses::mount_response_sequence(&mock_server, vec![unauthorized]).await; + + let initial_id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("initial@example.com") + .plan_type("pro") + .chatgpt_account_id("org-initial"), + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let set_id = mcp + .send_chatgpt_auth_tokens_login_request(initial_id_token, "access-initial".to_string()) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + let _updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + + let thread_req = mcp + .send_thread_start_request(codex_app_server_protocol::ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let thread = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(codex_app_server_protocol::TurnStartParams { + thread_id: thread.thread.id.clone(), + input: vec![codex_app_server_protocol::UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + + let refresh_req: ServerRequest = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } = refresh_req else { + bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}"); + }; + + mcp.send_error( + request_id, + JSONRPCErrorError { + code: -32_000, + message: "refresh failed".to_string(), + data: None, + }, + ) + .await?; + + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + Ok(()) +} + +#[tokio::test] +// Refresh returns tokens for the wrong workspace; turn fails. +async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_workspace_id: Some("org-expected".to_string()), + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + let unauthorized = ResponseTemplate::new(401).set_body_json(json!({ + "error": { "message": "unauthorized" } + })); + let _responses_mock = + responses::mount_response_sequence(&mock_server, vec![unauthorized]).await; + + let initial_id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("initial@example.com") + .plan_type("pro") + .chatgpt_account_id("org-expected"), + )?; + let refreshed_id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("refreshed@example.com") + .plan_type("pro") + .chatgpt_account_id("org-other"), + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let set_id = mcp + .send_chatgpt_auth_tokens_login_request(initial_id_token, "access-initial".to_string()) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + let _updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + + let thread_req = mcp + .send_thread_start_request(codex_app_server_protocol::ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let thread = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(codex_app_server_protocol::TurnStartParams { + thread_id: thread.thread.id.clone(), + input: vec![codex_app_server_protocol::UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + + let refresh_req: ServerRequest = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } = refresh_req else { + bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}"); + }; + + mcp.send_response( + request_id, + serde_json::to_value(ChatgptAuthTokensRefreshResponse { + access_token: "access-refreshed".to_string(), + id_token: refreshed_id_token, + })?, + ) + .await?; + + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + Ok(()) +} + +#[tokio::test] +// Refresh returns a malformed id_token; turn fails. +async fn external_auth_refresh_invalid_id_token_fails_turn() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + let unauthorized = ResponseTemplate::new(401).set_body_json(json!({ + "error": { "message": "unauthorized" } + })); + let _responses_mock = + responses::mount_response_sequence(&mock_server, vec![unauthorized]).await; + + let initial_id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("initial@example.com") + .plan_type("pro") + .chatgpt_account_id("org-initial"), + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let set_id = mcp + .send_chatgpt_auth_tokens_login_request(initial_id_token, "access-initial".to_string()) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + let _updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + + let thread_req = mcp + .send_thread_start_request(codex_app_server_protocol::ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let thread = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(codex_app_server_protocol::TurnStartParams { + thread_id: thread.thread.id.clone(), + input: vec![codex_app_server_protocol::UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + + let refresh_req: ServerRequest = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } = refresh_req else { + bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}"); + }; + + mcp.send_response( + request_id, + serde_json::to_value(ChatgptAuthTokensRefreshResponse { + access_token: "access-refreshed".to_string(), + id_token: "not-a-jwt".to_string(), + })?, + ) + .await?; + + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + Ok(()) +} + #[tokio::test] async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; @@ -304,6 +944,71 @@ async fn login_account_chatgpt_start_can_be_cancelled() -> Result<()> { Ok(()) } +#[tokio::test] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] +async fn set_auth_token_cancels_active_chatgpt_login() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Initiate the ChatGPT login flow + let request_id = mcp.send_login_account_chatgpt_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::Chatgpt { login_id, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + + let id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("embedded@example.com") + .plan_type("pro") + .chatgpt_account_id("org-embedded"), + )?; + // Set an external auth token instead of completing the ChatGPT login flow. + // This should cancel the active login attempt. + let set_id = mcp + .send_chatgpt_auth_tokens_login_request(id_token, "access-embedded".to_string()) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + let _updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + + // Verify that the active login attempt was cancelled. + // We check this by trying to cancel it and expecting a not found error. + let cancel_id = mcp + .send_cancel_login_account_request(CancelLoginAccountParams { + login_id: login_id.clone(), + }) + .await?; + let cancel_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)), + ) + .await??; + let cancel: CancelLoginAccountResponse = to_response(cancel_resp)?; + assert_eq!(cancel.status, CancelLoginAccountStatus::NotFound); + + Ok(()) +} + #[tokio::test] // Serialize tests that launch the login server since it binds to a fixed port. #[serial(login_port)] diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 45ee459697..0f4e4eb39f 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -240,6 +240,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Logged in using ChatGPT"); std::process::exit(0); } + AuthMode::ChatgptAuthTokens => { + eprintln!("Logged in using ChatGPT (external tokens)"); + std::process::exit(0); + } }, Ok(None) => { eprintln!("Not logged in"); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index fe27f7e24e..cdd2717fd4 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -111,6 +111,13 @@ "auto" ], "type": "string" + }, + { + "description": "Store credentials in memory only for the current process.", + "enum": [ + "ephemeral" + ], + "type": "string" } ] }, diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index b746309270..d6cfec5506 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -1,5 +1,6 @@ mod storage; +use async_trait::async_trait; use chrono::Utc; use reqwest::StatusCode; use serde::Deserialize; @@ -23,6 +24,7 @@ use crate::auth::storage::create_auth_storage; use crate::config::Config; use crate::error::RefreshTokenFailedError; use crate::error::RefreshTokenFailedReason; +use crate::token_data::IdTokenInfo; use crate::token_data::KnownPlan as InternalKnownPlan; use crate::token_data::PlanType as InternalPlanType; use crate::token_data::TokenData; @@ -68,6 +70,31 @@ pub enum RefreshTokenError { Transient(#[from] std::io::Error), } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalAuthTokens { + pub access_token: String, + pub id_token: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExternalAuthRefreshReason { + Unauthorized, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalAuthRefreshContext { + pub reason: ExternalAuthRefreshReason, + pub previous_account_id: Option, +} + +#[async_trait] +pub trait ExternalAuthRefresher: Send + Sync { + async fn refresh( + &self, + context: ExternalAuthRefreshContext, + ) -> std::io::Result; +} + impl RefreshTokenError { pub fn failed_reason(&self) -> Option { match self { @@ -110,9 +137,9 @@ impl CodexAuth { pub fn get_token(&self) -> Result { match self.mode { AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()), - AuthMode::ChatGPT => { - let id_token = self.get_token_data()?.access_token; - Ok(id_token) + AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => { + let access_token = self.get_token_data()?.access_token; + Ok(access_token) } } } @@ -160,6 +187,7 @@ impl CodexAuth { /// Consider this private to integration tests. pub fn create_dummy_chatgpt_auth_for_testing() -> Self { let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::ChatGPT), openai_api_key: None, tokens: Some(TokenData { id_token: Default::default(), @@ -229,6 +257,7 @@ pub fn login_with_api_key( auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result<()> { let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), openai_api_key: Some(api_key.to_string()), tokens: None, last_refresh: None, @@ -236,6 +265,20 @@ pub fn login_with_api_key( save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } +/// Writes an in-memory auth payload for externally managed ChatGPT tokens. +pub fn login_with_chatgpt_auth_tokens( + codex_home: &Path, + id_token: &str, + access_token: &str, +) -> std::io::Result<()> { + let auth_dot_json = AuthDotJson::from_external_token_strings(id_token, access_token)?; + save_auth( + codex_home, + &auth_dot_json, + AuthCredentialsStoreMode::Ephemeral, + ) +} + /// Persist the provided auth payload using the specified backend. pub fn save_auth( codex_home: &Path, @@ -272,8 +315,8 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { if let Some(required_method) = config.forced_login_method { let method_violation = match (required_method, auth.mode) { (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, - (ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT) => None, - (ForcedLoginMethod::Api, AuthMode::ChatGPT) => Some( + (ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) => None, + (ForcedLoginMethod::Api, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." .to_string(), ), @@ -293,7 +336,7 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { } if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() { - if auth.mode != AuthMode::ChatGPT { + if !matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) { return Ok(()); } @@ -337,12 +380,26 @@ fn logout_with_message( message: String, auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result<()> { - match logout(codex_home, auth_credentials_store_mode) { - Ok(_) => Err(std::io::Error::other(message)), - Err(err) => Err(std::io::Error::other(format!( - "{message}. Failed to remove auth.json: {err}" - ))), + // External auth tokens live in the ephemeral store, but persistent auth may still exist + // from earlier logins. Clear both so a forced logout truly removes all active auth. + let removal_result = logout_all_stores(codex_home, auth_credentials_store_mode); + let error_message = match removal_result { + Ok(_) => message, + Err(err) => format!("{message}. Failed to remove auth.json: {err}"), + }; + Err(std::io::Error::other(error_message)) +} + +fn logout_all_stores( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result { + if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral { + return logout(codex_home, AuthCredentialsStoreMode::Ephemeral); } + let removed_ephemeral = logout(codex_home, AuthCredentialsStoreMode::Ephemeral)?; + let removed_managed = logout(codex_home, auth_credentials_store_mode)?; + Ok(removed_ephemeral || removed_managed) } fn load_auth( @@ -350,6 +407,12 @@ fn load_auth( enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result> { + let build_auth = |auth_dot_json: AuthDotJson, storage_mode| { + let client = crate::default_client::create_client(); + CodexAuth::from_auth_dot_json(codex_home, auth_dot_json, storage_mode, client) + }; + + // API key via env var takes precedence over any other auth method. if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { let client = crate::default_client::create_client(); return Ok(Some(CodexAuth::from_api_key_with_client( @@ -358,39 +421,34 @@ fn load_auth( ))); } - let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + // External ChatGPT auth tokens live in the in-memory (ephemeral) store. Always check this + // first so external auth takes precedence over any persisted credentials. + let ephemeral_storage = create_auth_storage( + codex_home.to_path_buf(), + AuthCredentialsStoreMode::Ephemeral, + ); + if let Some(auth_dot_json) = ephemeral_storage.load()? { + let auth = build_auth(auth_dot_json, AuthCredentialsStoreMode::Ephemeral)?; + return Ok(Some(auth)); + } - let client = crate::default_client::create_client(); + // If the caller explicitly requested ephemeral auth, there is no persisted fallback. + if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral { + return Ok(None); + } + + // Fall back to the configured persistent store (file/keyring/auto) for managed auth. + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); let auth_dot_json = match storage.load()? { Some(auth) => auth, None => return Ok(None), }; - let AuthDotJson { - openai_api_key: auth_json_api_key, - tokens, - last_refresh, - } = auth_dot_json; - - // Prefer AuthMode.ApiKey if it's set in the auth.json. - if let Some(api_key) = &auth_json_api_key { - return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); - } - - Ok(Some(CodexAuth { - api_key: None, - mode: AuthMode::ChatGPT, - storage: storage.clone(), - auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { - openai_api_key: None, - tokens, - last_refresh, - }))), - client, - })) + let auth = build_auth(auth_dot_json, auth_credentials_store_mode)?; + Ok(Some(auth)) } -async fn update_tokens( +fn update_tokens( storage: &Arc, id_token: Option, access_token: Option, @@ -537,17 +595,108 @@ fn refresh_token_endpoint() -> String { .unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string()) } +impl AuthDotJson { + fn from_external_tokens(external: &ExternalAuthTokens, id_token: IdTokenInfo) -> Self { + let account_id = id_token.chatgpt_account_id.clone(); + let tokens = TokenData { + id_token, + access_token: external.access_token.clone(), + refresh_token: String::new(), + account_id, + }; + + Self { + auth_mode: Some(AuthMode::ChatgptAuthTokens), + openai_api_key: None, + tokens: Some(tokens), + last_refresh: Some(Utc::now()), + } + } + + fn from_external_token_strings(id_token: &str, access_token: &str) -> std::io::Result { + let id_token_info = parse_id_token(id_token).map_err(std::io::Error::other)?; + let external = ExternalAuthTokens { + access_token: access_token.to_string(), + id_token: id_token.to_string(), + }; + Ok(Self::from_external_tokens(&external, id_token_info)) + } + + fn resolved_mode(&self) -> AuthMode { + if let Some(mode) = self.auth_mode { + return mode; + } + if self.openai_api_key.is_some() { + return AuthMode::ApiKey; + } + AuthMode::ChatGPT + } + + fn storage_mode( + &self, + auth_credentials_store_mode: AuthCredentialsStoreMode, + ) -> AuthCredentialsStoreMode { + if self.resolved_mode() == AuthMode::ChatgptAuthTokens { + AuthCredentialsStoreMode::Ephemeral + } else { + auth_credentials_store_mode + } + } +} + +impl CodexAuth { + fn from_auth_dot_json( + codex_home: &Path, + auth_dot_json: AuthDotJson, + auth_credentials_store_mode: AuthCredentialsStoreMode, + client: CodexHttpClient, + ) -> std::io::Result { + let auth_mode = auth_dot_json.resolved_mode(); + if auth_mode == AuthMode::ApiKey { + let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else { + return Err(std::io::Error::other("API key auth is missing a key.")); + }; + return Ok(CodexAuth::from_api_key_with_client(api_key, client)); + } + + let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); + let storage = create_auth_storage(codex_home.to_path_buf(), storage_mode); + Ok(Self { + api_key: None, + mode: auth_mode, + storage, + auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), + client, + }) + } +} + use std::sync::RwLock; /// Internal cached auth state. -#[derive(Clone, Debug)] +#[derive(Clone)] struct CachedAuth { auth: Option, + /// Callback used to refresh external auth by asking the parent app for new tokens. + external_refresher: Option>, +} + +impl Debug for CachedAuth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CachedAuth") + .field("auth_mode", &self.auth.as_ref().map(|auth| auth.mode)) + .field( + "external_refresher", + &self.external_refresher.as_ref().map(|_| "present"), + ) + .finish() + } } enum UnauthorizedRecoveryStep { Reload, RefreshToken, + ExternalRefresh, Done, } @@ -556,38 +705,63 @@ enum ReloadOutcome { Skipped, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UnauthorizedRecoveryMode { + Managed, + External, +} + // UnauthorizedRecovery is a state machine that handles an attempt to refresh the authentication when requests // to API fail with 401 status code. // The client calls next() every time it encounters a 401 error, one time per retry. // For API key based authentication, we don't do anything and let the error bubble to the user. +// // For ChatGPT based authentication, we: // 1. Attempt to reload the auth data from disk. We only reload if the account id matches the one the current process is running as. // 2. Attempt to refresh the token using OAuth token refresh flow. // If after both steps the server still responds with 401 we let the error bubble to the user. +// +// For external ChatGPT auth tokens (chatgptAuthTokens), UnauthorizedRecovery does not touch disk or refresh +// tokens locally. Instead it calls the ExternalAuthRefresher (account/chatgptAuthTokens/refresh) to ask the +// parent app for new tokens, stores them in the ephemeral auth store, and retries once. pub struct UnauthorizedRecovery { manager: Arc, step: UnauthorizedRecoveryStep, expected_account_id: Option, + mode: UnauthorizedRecoveryMode, } impl UnauthorizedRecovery { fn new(manager: Arc) -> Self { - let expected_account_id = manager - .auth_cached() - .as_ref() - .and_then(CodexAuth::get_account_id); + let cached_auth = manager.auth_cached(); + let expected_account_id = cached_auth.as_ref().and_then(CodexAuth::get_account_id); + let mode = match cached_auth { + Some(auth) if auth.mode == AuthMode::ChatgptAuthTokens => { + UnauthorizedRecoveryMode::External + } + _ => UnauthorizedRecoveryMode::Managed, + }; + let step = match mode { + UnauthorizedRecoveryMode::Managed => UnauthorizedRecoveryStep::Reload, + UnauthorizedRecoveryMode::External => UnauthorizedRecoveryStep::ExternalRefresh, + }; Self { manager, - step: UnauthorizedRecoveryStep::Reload, + step, expected_account_id, + mode, } } pub fn has_next(&self) -> bool { - if !self - .manager - .auth_cached() - .is_some_and(|auth| auth.mode == AuthMode::ChatGPT) + if !self.manager.auth_cached().is_some_and(|auth| { + matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) + }) { + return false; + } + + if self.mode == UnauthorizedRecoveryMode::External + && !self.manager.has_external_auth_refresher() { return false; } @@ -622,6 +796,12 @@ impl UnauthorizedRecovery { self.manager.refresh_token().await?; self.step = UnauthorizedRecoveryStep::Done; } + UnauthorizedRecoveryStep::ExternalRefresh => { + self.manager + .refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) + .await?; + self.step = UnauthorizedRecoveryStep::Done; + } UnauthorizedRecoveryStep::Done => {} } Ok(()) @@ -642,6 +822,7 @@ pub struct AuthManager { inner: RwLock, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: RwLock>, } impl AuthManager { @@ -654,7 +835,7 @@ impl AuthManager { enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> Self { - let auth = load_auth( + let managed_auth = load_auth( &codex_home, enable_codex_api_key_env, auth_credentials_store_mode, @@ -663,34 +844,46 @@ impl AuthManager { .flatten(); Self { codex_home, - inner: RwLock::new(CachedAuth { auth }), + inner: RwLock::new(CachedAuth { + auth: managed_auth, + external_refresher: None, + }), enable_codex_api_key_env, auth_credentials_store_mode, + forced_chatgpt_workspace_id: RwLock::new(None), } } #[cfg(any(test, feature = "test-support"))] /// Create an AuthManager with a specific CodexAuth, for testing only. pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { - let cached = CachedAuth { auth: Some(auth) }; + let cached = CachedAuth { + auth: Some(auth), + external_refresher: None, + }; Arc::new(Self { codex_home: PathBuf::from("non-existent"), inner: RwLock::new(cached), enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_chatgpt_workspace_id: RwLock::new(None), }) } #[cfg(any(test, feature = "test-support"))] /// Create an AuthManager with a specific CodexAuth and codex home, for testing only. pub fn from_auth_for_testing_with_home(auth: CodexAuth, codex_home: PathBuf) -> Arc { - let cached = CachedAuth { auth: Some(auth) }; + let cached = CachedAuth { + auth: Some(auth), + external_refresher: None, + }; Arc::new(Self { codex_home, inner: RwLock::new(cached), enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_chatgpt_workspace_id: RwLock::new(None), }) } @@ -715,7 +908,7 @@ impl AuthManager { pub fn reload(&self) -> bool { tracing::info!("Reloading auth"); let new_auth = self.load_auth_from_storage(); - self.set_auth(new_auth) + self.set_cached_auth(new_auth) } fn reload_if_account_id_matches(&self, expected_account_id: Option<&str>) -> ReloadOutcome { @@ -739,11 +932,11 @@ impl AuthManager { } tracing::info!("Reloading auth for account {expected_account_id}"); - self.set_auth(new_auth); + self.set_cached_auth(new_auth); ReloadOutcome::Reloaded } - fn auths_equal(a: &Option, b: &Option) -> bool { + fn auths_equal(a: Option<&CodexAuth>, b: Option<&CodexAuth>) -> bool { match (a, b) { (None, None) => true, (Some(a), Some(b)) => a == b, @@ -761,9 +954,10 @@ impl AuthManager { .flatten() } - fn set_auth(&self, new_auth: Option) -> bool { + fn set_cached_auth(&self, new_auth: Option) -> bool { if let Ok(mut guard) = self.inner.write() { - let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); + let previous = guard.auth.as_ref(); + let changed = !AuthManager::auths_equal(previous, new_auth.as_ref()); tracing::info!("Reloaded auth, changed: {changed}"); guard.auth = new_auth; changed @@ -772,6 +966,38 @@ impl AuthManager { } } + pub fn set_external_auth_refresher(&self, refresher: Arc) { + if let Ok(mut guard) = self.inner.write() { + guard.external_refresher = Some(refresher); + } + } + + pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option) { + if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write() { + *guard = workspace_id; + } + } + + pub fn forced_chatgpt_workspace_id(&self) -> Option { + self.forced_chatgpt_workspace_id + .read() + .ok() + .and_then(|guard| guard.clone()) + } + + pub fn has_external_auth_refresher(&self) -> bool { + self.inner + .read() + .ok() + .map(|guard| guard.external_refresher.is_some()) + .unwrap_or(false) + } + + pub fn is_external_auth_active(&self) -> bool { + self.auth_cached() + .is_some_and(|auth| auth.mode == AuthMode::ChatgptAuthTokens) + } + /// Convenience constructor returning an `Arc` wrapper. pub fn shared( codex_home: PathBuf, @@ -799,6 +1025,11 @@ impl AuthManager { Some(auth) => auth, None => return Ok(()), }; + if auth.mode == AuthMode::ChatgptAuthTokens { + return self + .refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) + .await; + } let token_data = auth.get_current_token_data().ok_or_else(|| { RefreshTokenError::Transient(std::io::Error::other("Token data is not available.")) })?; @@ -813,7 +1044,7 @@ impl AuthManager { /// reloads the in‑memory auth cache so callers immediately observe the /// unauthenticated state. pub fn logout(&self) -> std::io::Result { - let removed = super::auth::logout(&self.codex_home, self.auth_credentials_store_mode)?; + let removed = logout_all_stores(&self.codex_home, self.auth_credentials_store_mode)?; // Always reload to clear any cached auth (even if file absent). self.reload(); Ok(removed) @@ -848,6 +1079,59 @@ impl AuthManager { Ok(true) } + async fn refresh_external_auth( + &self, + reason: ExternalAuthRefreshReason, + ) -> Result<(), RefreshTokenError> { + let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); + let refresher = match self.inner.read() { + Ok(guard) => guard.external_refresher.clone(), + Err(_) => { + return Err(RefreshTokenError::Transient(std::io::Error::other( + "failed to read external auth state", + ))); + } + }; + + let Some(refresher) = refresher else { + return Err(RefreshTokenError::Transient(std::io::Error::other( + "external auth refresher is not configured", + ))); + }; + + let previous_account_id = self + .auth_cached() + .as_ref() + .and_then(CodexAuth::get_account_id); + let context = ExternalAuthRefreshContext { + reason, + previous_account_id, + }; + + let refreshed = refresher.refresh(context).await?; + let id_token = parse_id_token(&refreshed.id_token) + .map_err(|err| RefreshTokenError::Transient(std::io::Error::other(err)))?; + if let Some(expected_workspace_id) = forced_chatgpt_workspace_id.as_deref() { + let actual_workspace_id = id_token.chatgpt_account_id.as_deref(); + if actual_workspace_id != Some(expected_workspace_id) { + return Err(RefreshTokenError::Transient(std::io::Error::other( + format!( + "external auth refresh returned workspace {actual_workspace_id:?}, expected {expected_workspace_id:?}", + ), + ))); + } + } + let auth_dot_json = AuthDotJson::from_external_tokens(&refreshed, id_token); + save_auth( + &self.codex_home, + &auth_dot_json, + AuthCredentialsStoreMode::Ephemeral, + ) + .map_err(RefreshTokenError::Transient)?; + self.reload(); + Ok(()) + } + async fn refresh_tokens( &self, auth: &CodexAuth, @@ -861,7 +1145,6 @@ impl AuthManager { refresh_response.access_token, refresh_response.refresh_token, ) - .await .map_err(RefreshTokenError::from)?; Ok(()) @@ -910,7 +1193,6 @@ mod tests { Some("new-access-token".to_string()), Some("new-refresh-token".to_string()), ) - .await .expect("update_tokens should succeed"); let tokens = updated.tokens.expect("tokens should exist"); @@ -991,6 +1273,7 @@ mod tests { assert_eq!( &AuthDotJson { + auth_mode: None, openai_api_key: None, tokens: Some(TokenData { id_token: IdTokenInfo { @@ -1034,6 +1317,7 @@ mod tests { fn logout_removes_auth_file() -> Result<(), std::io::Error> { let dir = tempdir()?; let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), openai_api_key: Some("sk-test-key".to_string()), tokens: None, last_refresh: None, diff --git a/codex-rs/core/src/auth/storage.rs b/codex-rs/core/src/auth/storage.rs index 48b67aca05..b4f4bb8e7e 100644 --- a/codex-rs/core/src/auth/storage.rs +++ b/codex-rs/core/src/auth/storage.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use serde::Serialize; use sha2::Digest; use sha2::Sha256; +use std::collections::HashMap; use std::fmt::Debug; use std::fs::File; use std::fs::OpenOptions; @@ -15,11 +16,14 @@ use std::os::unix::fs::OpenOptionsExt; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::sync::Mutex; use tracing::warn; use crate::token_data::TokenData; +use codex_app_server_protocol::AuthMode; use codex_keyring_store::DefaultKeyringStore; use codex_keyring_store::KeyringStore; +use once_cell::sync::Lazy; /// Determine where Codex should store CLI auth credentials. #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -32,11 +36,16 @@ pub enum AuthCredentialsStoreMode { Keyring, /// Use keyring when available; otherwise, fall back to a file in CODEX_HOME. Auto, + /// Store credentials in memory only for the current process. + Ephemeral, } /// Expected structure for $CODEX_HOME/auth.json. #[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct AuthDotJson { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_mode: Option, + #[serde(rename = "OPENAI_API_KEY")] pub openai_api_key: Option, @@ -76,8 +85,8 @@ impl FileAuthStorage { Self { codex_home } } - /// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. - /// Returns the full AuthDotJson structure after refreshing if necessary. + /// Attempt to read and parse the `auth.json` file in the given `CODEX_HOME` directory. + /// Returns the full AuthDotJson structure. pub(super) fn try_read_auth_json(&self, auth_file: &Path) -> std::io::Result { let mut file = File::open(auth_file)?; let mut contents = String::new(); @@ -256,6 +265,49 @@ impl AuthStorageBackend for AutoAuthStorage { } } +// A global in-memory store for mapping codex_home -> AuthDotJson. +static EPHEMERAL_AUTH_STORE: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +#[derive(Clone, Debug)] +struct EphemeralAuthStorage { + codex_home: PathBuf, +} + +impl EphemeralAuthStorage { + fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + fn with_store(&self, action: F) -> std::io::Result + where + F: FnOnce(&mut HashMap, String) -> std::io::Result, + { + let key = compute_store_key(&self.codex_home)?; + let mut store = EPHEMERAL_AUTH_STORE + .lock() + .map_err(|_| std::io::Error::other("failed to lock ephemeral auth storage"))?; + action(&mut store, key) + } +} + +impl AuthStorageBackend for EphemeralAuthStorage { + fn load(&self) -> std::io::Result> { + self.with_store(|store, key| Ok(store.get(&key).cloned())) + } + + fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> { + self.with_store(|store, key| { + store.insert(key, auth.clone()); + Ok(()) + }) + } + + fn delete(&self) -> std::io::Result { + self.with_store(|store, key| Ok(store.remove(&key).is_some())) + } +} + pub(super) fn create_auth_storage( codex_home: PathBuf, mode: AuthCredentialsStoreMode, @@ -275,6 +327,7 @@ fn create_auth_storage_with_keyring_store( Arc::new(KeyringAuthStorage::new(codex_home, keyring_store)) } AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new(codex_home, keyring_store)), + AuthCredentialsStoreMode::Ephemeral => Arc::new(EphemeralAuthStorage::new(codex_home)), } } @@ -296,6 +349,7 @@ mod tests { let codex_home = tempdir()?; let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), openai_api_key: Some("test-key".to_string()), tokens: None, last_refresh: Some(Utc::now()), @@ -315,6 +369,7 @@ mod tests { let codex_home = tempdir()?; let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), openai_api_key: Some("test-key".to_string()), tokens: None, last_refresh: Some(Utc::now()), @@ -336,6 +391,7 @@ mod tests { fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { let dir = tempdir()?; let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), openai_api_key: Some("sk-test-key".to_string()), tokens: None, last_refresh: None, @@ -350,6 +406,32 @@ mod tests { Ok(()) } + #[test] + fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> { + let dir = tempdir()?; + let storage = create_auth_storage( + dir.path().to_path_buf(), + AuthCredentialsStoreMode::Ephemeral, + ); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some("sk-ephemeral".to_string()), + tokens: None, + last_refresh: Some(Utc::now()), + }; + + storage.save(&auth_dot_json)?; + let loaded = storage.load()?; + assert_eq!(Some(auth_dot_json), loaded); + + let removed = storage.delete()?; + assert!(removed); + let loaded = storage.load()?; + assert_eq!(None, loaded); + assert!(!get_auth_file(dir.path()).exists()); + Ok(()) + } + fn seed_keyring_and_fallback_auth_file_for_delete( mock_keyring: &MockKeyringStore, codex_home: &Path, @@ -425,6 +507,7 @@ mod tests { fn auth_with_prefix(prefix: &str) -> AuthDotJson { AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), openai_api_key: Some(format!("{prefix}-api-key")), tokens: Some(TokenData { id_token: id_token_with_prefix(prefix), @@ -445,6 +528,7 @@ mod tests { Arc::new(mock_keyring.clone()), ); let expected = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), openai_api_key: Some("sk-test".to_string()), tokens: None, last_refresh: None, @@ -481,6 +565,7 @@ mod tests { let auth_file = get_auth_file(codex_home.path()); std::fs::write(&auth_file, "stale")?; let auth = AuthDotJson { + auth_mode: Some(AuthMode::ChatGPT), openai_api_key: None, tokens: Some(TokenData { id_token: Default::default(), diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 4cb37a46d4..0f05f58c47 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -469,7 +469,9 @@ impl ModelClientSession { .config .features .enabled(Feature::EnableRequestCompression) - && auth.is_some_and(|auth| auth.mode == AuthMode::ChatGPT) + && auth.is_some_and(|auth| { + matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) + }) && self.state.provider.is_openai() { Compression::Zstd diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 4f69f02d91..0f3551480c 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -137,7 +137,10 @@ impl ModelProviderInfo { &self, auth_mode: Option, ) -> crate::error::Result { - let default_base_url = if matches!(auth_mode, Some(AuthMode::ChatGPT)) { + let default_base_url = if matches!( + auth_mode, + Some(AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) + ) { "https://chatgpt.com/backend-api/codex" } else { "https://api.openai.com/v1" diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 77fb44cd5d..c993032ea9 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -204,7 +204,8 @@ impl ModelsManager { let _timer = codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]); let auth = self.auth_manager.auth().await; - let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?; + let auth_mode = self.auth_manager.get_auth_mode(); + let api_provider = self.provider.to_api_provider(auth_mode)?; let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?; let transport = ReqwestTransport::new(build_reqwest_client()); let client = ModelsClient::new(transport, api_provider, api_auth); @@ -271,7 +272,10 @@ impl ModelsManager { let remote_presets: Vec = remote_models.into_iter().map(Into::into).collect(); let existing_presets = self.local_models.clone(); let mut merged_presets = ModelPreset::merge(remote_presets, existing_presets); - let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT); + let chatgpt_mode = matches!( + self.auth_manager.get_auth_mode(), + Some(AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) + ); merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode); for preset in &mut merged_presets { diff --git a/codex-rs/core/src/token_data.rs b/codex-rs/core/src/token_data.rs index 744cc73d9b..2010af16f7 100644 --- a/codex-rs/core/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -76,10 +76,18 @@ pub(crate) enum KnownPlan { struct IdClaims { #[serde(default)] email: Option, + #[serde(rename = "https://api.openai.com/profile", default)] + profile: Option, #[serde(rename = "https://api.openai.com/auth", default)] auth: Option, } +#[derive(Deserialize)] +struct ProfileClaims { + #[serde(default)] + email: Option, +} + #[derive(Deserialize)] struct AuthClaims { #[serde(default)] @@ -112,17 +120,20 @@ pub fn parse_id_token(id_token: &str) -> Result { let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?; let claims: IdClaims = serde_json::from_slice(&payload_bytes)?; + let email = claims + .email + .or_else(|| claims.profile.and_then(|profile| profile.email)); match claims.auth { Some(auth) => Ok(IdTokenInfo { - email: claims.email, + email, raw_jwt: id_token.to_string(), chatgpt_plan_type: auth.chatgpt_plan_type, chatgpt_user_id: auth.chatgpt_user_id.or(auth.user_id), chatgpt_account_id: auth.chatgpt_account_id, }), None => Ok(IdTokenInfo { - email: claims.email, + email, raw_jwt: id_token.to_string(), chatgpt_plan_type: None, chatgpt_user_id: None, diff --git a/codex-rs/core/tests/suite/auth_refresh.rs b/codex-rs/core/tests/suite/auth_refresh.rs index d0b8d27382..4ef3b82eec 100644 --- a/codex-rs/core/tests/suite/auth_refresh.rs +++ b/codex-rs/core/tests/suite/auth_refresh.rs @@ -3,6 +3,7 @@ use anyhow::Result; use base64::Engine; use chrono::Duration; use chrono::Utc; +use codex_app_server_protocol::AuthMode; use codex_core::AuthManager; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::AuthDotJson; @@ -50,6 +51,7 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { let initial_last_refresh = Utc::now() - Duration::days(1); 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.clone()), last_refresh: Some(initial_last_refresh), @@ -111,6 +113,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> { let initial_last_refresh = Utc::now() - Duration::days(1); 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.clone()), last_refresh: Some(initial_last_refresh), @@ -156,6 +159,7 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> { 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.clone()), last_refresh: Some(stale_refresh), @@ -214,6 +218,7 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re let initial_last_refresh = Utc::now() - Duration::days(1); 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.clone()), last_refresh: Some(initial_last_refresh), @@ -263,6 +268,7 @@ async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> let initial_last_refresh = Utc::now() - Duration::days(1); 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.clone()), last_refresh: Some(initial_last_refresh), @@ -314,6 +320,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { let initial_last_refresh = Utc::now() - Duration::days(1); 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.clone()), last_refresh: Some(initial_last_refresh), @@ -322,6 +329,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { 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(initial_last_refresh), @@ -404,6 +412,7 @@ async fn unauthorized_recovery_skips_reload_on_account_mismatch() -> Result<()> let initial_last_refresh = Utc::now() - Duration::days(1); 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.clone()), last_refresh: Some(initial_last_refresh), @@ -418,6 +427,7 @@ async fn unauthorized_recovery_skips_reload_on_account_mismatch() -> Result<()> ..disk_tokens.clone() }; let disk_auth = AuthDotJson { + auth_mode: Some(AuthMode::ChatGPT), openai_api_key: None, tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), @@ -481,6 +491,7 @@ async fn unauthorized_recovery_requires_chatgpt_auth() -> Result<()> { let server = MockServer::start().await; let ctx = RefreshTokenTestContext::new(&server)?; let auth = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), openai_api_key: Some("sk-test".to_string()), tokens: None, last_refresh: None, diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 999c19072e..5272c84eaa 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -14,6 +14,7 @@ use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; 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; @@ -559,6 +560,7 @@ pub(crate) async fn persist_tokens_async( tokens.account_id = Some(acc.to_string()); } let auth = AuthDotJson { + auth_mode: Some(AuthMode::ChatGPT), openai_api_key: api_key, tokens: Some(tokens), last_refresh: Some(Utc::now()), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c132130515..61006d80c8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3457,7 +3457,10 @@ impl ChatWidget { fn prefetch_rate_limits(&mut self) { self.stop_rate_limit_poller(); - if self.auth_manager.auth_cached().map(|auth| auth.mode) != Some(AuthMode::ChatGPT) { + if !matches!( + self.auth_manager.auth_cached().map(|auth| auth.mode), + Some(AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) + ) { return; } @@ -3470,7 +3473,7 @@ impl ChatWidget { loop { if let Some(auth) = auth_manager.auth().await - && auth.mode == AuthMode::ChatGPT + && matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) && let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth).await { app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot)); diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 551fa9180e..daf64f36d8 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -661,7 +661,10 @@ impl AuthModeWidget { } fn handle_existing_chatgpt_login(&mut self) -> bool { - if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { + if matches!( + self.login_status, + LoginStatus::AuthMode(AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) + ) { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; self.request_frame.schedule_frame(); true diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index 78a956b27c..91f958a9cf 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -91,7 +91,7 @@ pub(crate) fn compose_account_display( let auth = auth_manager.auth_cached()?; match auth.mode { - AuthMode::ChatGPT => { + AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => { let email = auth.get_account_email(); let plan = plan .map(|plan_type| title_case(format!("{plan_type:?}").as_str())) From 11958221a3354fe7941671cc2c4a24ffd73c29a7 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Thu, 29 Jan 2026 16:40:43 -0800 Subject: [PATCH 38/70] tui: add feature-gated /plan slash command to switch to Plan mode (#10103) ## Summary Adds a simple `/plan` slash command in the TUI that switches the active collaboration mode to Plan mode. The command is only available when the `collaboration_modes` feature is enabled. ## Changes - Add `plan_mask` helper in `codex-rs/tui/src/collaboration_modes.rs` - Add `SlashCommand::Plan` metadata in `codex-rs/tui/src/slash_command.rs` - Implement and hard-gate `/plan` dispatch in `codex-rs/tui/src/chatwidget.rs` - Hide `/plan` when collaboration modes are disabled in `codex-rs/tui/src/bottom_pane/slash_commands.rs` - Update command popup tests in `codex-rs/tui/src/bottom_pane/command_popup.rs` - Add a focused unit test for `/plan` in `codex-rs/tui/src/chatwidget/tests.rs` ## Behavior notes - `/plan` is now a no-op if `Feature::CollaborationModes` is disabled. - When enabled, `/plan` switches directly to Plan mode without opening the picker. ## Codex author `codex resume 019c05da-d7c3-7322-ae2c-3ca38d0ef702` --- codex-rs/tui/src/bottom_pane/command_popup.rs | 25 ++++++++++++++- .../tui/src/bottom_pane/slash_commands.rs | 5 ++- codex-rs/tui/src/chatwidget.rs | 31 ++++++++++++++----- codex-rs/tui/src/chatwidget/tests.rs | 13 ++++++++ codex-rs/tui/src/collaboration_modes.rs | 4 +++ codex-rs/tui/src/slash_command.rs | 3 ++ 6 files changed, 72 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 621683923d..48ee7d2d60 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -459,7 +459,7 @@ mod tests { #[test] fn collab_command_hidden_when_collaboration_modes_disabled() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - popup.on_composer_text_change("/coll".to_string()); + popup.on_composer_text_change("/".to_string()); let cmds: Vec<&str> = popup .filtered_items() @@ -473,6 +473,10 @@ mod tests { !cmds.contains(&"collab"), "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" ); + assert!( + !cmds.contains(&"plan"), + "expected '/plan' to be hidden when collaboration modes are disabled, got {cmds:?}" + ); } #[test] @@ -494,6 +498,25 @@ mod tests { } } + #[test] + fn plan_command_visible_when_collaboration_modes_enabled() { + let mut popup = CommandPopup::new( + Vec::new(), + CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + personality_command_enabled: true, + windows_degraded_sandbox_active: false, + }, + ); + popup.on_composer_text_change("/plan".to_string()); + + match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "plan"), + other => panic!("expected plan to be selected for exact match, got {other:?}"), + } + } + #[test] fn personality_command_hidden_when_disabled() { let mut popup = CommandPopup::new( diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 1efab04ecd..34ad17330d 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -18,7 +18,10 @@ pub(crate) fn builtins_for_input( built_in_slash_commands() .into_iter() .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) - .filter(|(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) + .filter(|(_, cmd)| { + collaboration_modes_enabled + || !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan) + }) .filter(|(_, cmd)| connectors_enabled || *cmd != SlashCommand::Apps) .filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality) .collect() diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 61006d80c8..74200e470a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2668,10 +2668,29 @@ impl ChatWidget { SlashCommand::Personality => { self.open_personality_popup(); } - SlashCommand::Collab => { - if self.collaboration_modes_enabled() { - self.open_collaboration_modes_popup(); + SlashCommand::Plan => { + if !self.collaboration_modes_enabled() { + self.add_info_message( + "Collaboration modes are disabled.".to_string(), + Some("Enable collaboration modes to use /plan.".to_string()), + ); + return; } + if let Some(mask) = collaboration_modes::plan_mask(self.models_manager.as_ref()) { + self.set_collaboration_mask(mask); + } else { + self.add_info_message("Plan mode unavailable right now.".to_string(), None); + } + } + SlashCommand::Collab => { + if !self.collaboration_modes_enabled() { + self.add_info_message( + "Collaboration modes are disabled.".to_string(), + Some("Enable collaboration modes to use /collab.".to_string()), + ); + return; + } + self.open_collaboration_modes_popup(); } SlashCommand::Agent => { self.app_event_tx.send(AppEvent::OpenAgentPicker); @@ -2844,11 +2863,9 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { - SlashCommand::Collab => { + SlashCommand::Collab | SlashCommand::Plan => { let _ = trimmed; - if self.collaboration_modes_enabled() { - self.open_collaboration_modes_popup(); - } + self.dispatch_command(cmd); } SlashCommand::Review if !trimmed.is_empty() => { self.submit_op(Op::Review { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 9859e9e56f..d8f22dc8e3 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2267,6 +2267,19 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { } } +#[tokio::test] +async fn plan_slash_command_switches_to_plan_mode() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let initial = chat.current_collaboration_mode().clone(); + + chat.dispatch_command(SlashCommand::Plan); + + assert!(rx.try_recv().is_err(), "plan should not emit an app event"); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!(chat.current_collaboration_mode(), &initial); +} + #[tokio::test] async fn collaboration_modes_defaults_to_code_on_startup() { let codex_home = tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs index 95908d8e90..e799ea9c5a 100644 --- a/codex-rs/tui/src/collaboration_modes.rs +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -59,3 +59,7 @@ pub(crate) fn next_mask( pub(crate) fn code_mask(models_manager: &ModelsManager) -> Option { mask_for_kind(models_manager, ModeKind::Code) } + +pub(crate) fn plan_mask(models_manager: &ModelsManager) -> Option { + mask_for_kind(models_manager, ModeKind::Plan) +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 9bab314610..9488577c1b 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -25,6 +25,7 @@ pub enum SlashCommand { Fork, Init, Compact, + Plan, Collab, Agent, // Undo, @@ -63,6 +64,7 @@ impl SlashCommand { SlashCommand::Ps => "list background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Personality => "choose a communication style for Codex", + SlashCommand::Plan => "switch to Plan mode", SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Agent => "switch the active agent thread", SlashCommand::Approvals => "choose what Codex can do without approval", @@ -112,6 +114,7 @@ impl SlashCommand { | SlashCommand::Exit => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, + SlashCommand::Plan => true, SlashCommand::Collab => true, SlashCommand::Agent => true, } From c6e1288ef10aa88be6b32be531ee099dd538b60d Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Thu, 29 Jan 2026 16:48:15 -0800 Subject: [PATCH 39/70] chore(app-server): document AuthMode (#10191) Explain what this is and what it's used for. --- codex-rs/app-server-protocol/src/protocol/common.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 2aaf419440..b9c58a5883 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -23,12 +23,22 @@ impl GitSha { } } +/// Authentication mode for OpenAI-backed providers. +/// +/// This is used internally to determine the base URL for generating responses, and to gate +/// ChatGPT-only behaviors like rate limits and available models (as opposed to API key-based +/// auth). #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum AuthMode { + /// OpenAI API key provided by the caller and stored by Codex. ApiKey, + /// ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex). ChatGPT, /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. + /// + /// ChatGPT auth tokens are supplied by an external host app and are only + /// stored in memory. Token refresh must be handled by the external host app. #[serde(rename = "chatgptAuthTokens")] #[ts(rename = "chatgptAuthTokens")] #[strum(serialize = "chatgptAuthTokens")] From 7151387474792e9febcc3cb6e01ceb98280ce5de Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Thu, 29 Jan 2026 17:10:00 -0800 Subject: [PATCH 40/70] [feat] persist dynamic tools in session rollout file (#10130) Add dynamic tools to rollout file for persistence & read from rollout on resume. Ran a real example and spotted the following in the rollout file: ``` {"timestamp":"2026-01-29T01:27:57.468Z","type":"session_meta","payload":{"id":"019c075d-3f0b-77e3-894e-c1c159b04b1e","timestamp":"2026-01-29T01:27:57.451Z","...."dynamic_tools":[{"name":"demo_tool","description":"Demo dynamic tool","inputSchema":{"additionalProperties":false,"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"}}],"git":{"commit_hash":"ebc573f15c01b8af158e060cfedd401f043e9dfa","branch":"dev/cc/dynamic-tools","repository_url":"https://github.com/openai/codex.git"}}} ``` --- codex-rs/app-server/tests/common/rollout.rs | 2 ++ codex-rs/core/src/codex.rs | 7 +++++++ codex-rs/core/src/rollout/metadata.rs | 1 + codex-rs/core/src/rollout/recorder.rs | 10 ++++++++++ codex-rs/core/src/rollout/tests.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/protocol/src/protocol.rs | 20 ++++++++++++++++++++ 7 files changed, 42 insertions(+) diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 26316eddbd..14dce02c64 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -81,6 +81,7 @@ pub fn create_fake_rollout_with_source( source, model_provider: model_provider.map(str::to_string), base_instructions: None, + dynamic_tools: None, }; let payload = serde_json::to_value(SessionMetaLine { meta, @@ -159,6 +160,7 @@ pub fn create_fake_rollout_with_text_elements( source: SessionSource::Cli, model_provider: model_provider.map(str::to_string), base_instructions: None, + dynamic_tools: None, }; let payload = serde_json::to_value(SessionMetaLine { meta, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b7b7312c7..1fb5572183 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -324,6 +324,12 @@ impl Codex { .clone() .or_else(|| conversation_history.get_base_instructions().map(|s| s.text)) .unwrap_or_else(|| model_info.get_model_instructions(config.model_personality)); + // Respect explicit thread-start tools; fall back to persisted tools when resuming a thread. + let dynamic_tools = if dynamic_tools.is_empty() { + conversation_history.get_dynamic_tools().unwrap_or_default() + } else { + dynamic_tools + }; // TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode // to avoid extracting these fields separately and constructing CollaborationMode here. @@ -715,6 +721,7 @@ impl Session { BaseInstructions { text: session_configuration.base_instructions.clone(), }, + session_configuration.dynamic_tools.clone(), ), ) } diff --git a/codex-rs/core/src/rollout/metadata.rs b/codex-rs/core/src/rollout/metadata.rs index d08e77e241..5523274bde 100644 --- a/codex-rs/core/src/rollout/metadata.rs +++ b/codex-rs/core/src/rollout/metadata.rs @@ -303,6 +303,7 @@ mod tests { source: SessionSource::default(), model_provider: Some("openai".to_string()), base_instructions: None, + dynamic_tools: None, }; let session_meta_line = SessionMetaLine { meta: session_meta, diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index cc35850544..8157133165 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -7,6 +7,7 @@ use std::path::Path; use std::path::PathBuf; use codex_protocol::ThreadId; +use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; use serde_json::Value; use time::OffsetDateTime; @@ -68,6 +69,7 @@ pub enum RolloutRecorderParams { forked_from_id: Option, source: SessionSource, base_instructions: BaseInstructions, + dynamic_tools: Vec, }, Resume { path: PathBuf, @@ -91,12 +93,14 @@ impl RolloutRecorderParams { forked_from_id: Option, source: SessionSource, base_instructions: BaseInstructions, + dynamic_tools: Vec, ) -> Self { Self::Create { conversation_id, forked_from_id, source, base_instructions, + dynamic_tools, } } @@ -259,6 +263,7 @@ impl RolloutRecorder { forked_from_id, source, base_instructions, + dynamic_tools, } => { let LogFileInfo { file, @@ -288,6 +293,11 @@ impl RolloutRecorder { source, model_provider: Some(config.model_provider_id.clone()), base_instructions: Some(base_instructions), + dynamic_tools: if dynamic_tools.is_empty() { + None + } else { + Some(dynamic_tools) + }, }), ) } diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index 34afae2c94..cdf16291d3 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -873,6 +873,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { source: SessionSource::VSCode, model_provider: Some("test-provider".into()), base_instructions: None, + dynamic_tools: None, }, git: None, }), diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index bf3b5a4f51..2582f90cbe 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -93,6 +93,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { source: SessionSource::default(), model_provider: None, base_instructions: None, + dynamic_tools: None, }, git: None, }; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index aea7d3f013..7ba01fa4bd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -20,6 +20,7 @@ use crate::config_types::WindowsSandboxLevel; use crate::custom_prompts::CustomPrompt; use crate::dynamic_tools::DynamicToolCallRequest; use crate::dynamic_tools::DynamicToolResponse; +use crate::dynamic_tools::DynamicToolSpec; use crate::items::TurnItem; use crate::message_history::HistoryEntry; use crate::models::BaseInstructions; @@ -1513,6 +1514,22 @@ impl InitialHistory { }), } } + + pub fn get_dynamic_tools(&self) -> Option> { + match self { + InitialHistory::New => None, + InitialHistory::Resumed(resumed) => { + resumed.history.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.dynamic_tools.clone(), + _ => None, + }) + } + InitialHistory::Forked(items) => items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.dynamic_tools.clone(), + _ => None, + }), + } + } } fn session_cwd_from_items(items: &[RolloutItem]) -> Option { @@ -1599,6 +1616,8 @@ pub struct SessionMeta { /// but may be missing for older sessions. If not present, fall back to rendering the base_instructions /// from ModelsManager. pub base_instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dynamic_tools: Option>, } impl Default for SessionMeta { @@ -1613,6 +1632,7 @@ impl Default for SessionMeta { source: SessionSource::default(), model_provider: None, base_instructions: None, + dynamic_tools: None, } } } From a9cf449a80e9bfaf059651e3b8be9badcca4b145 Mon Sep 17 00:00:00 2001 From: willwang-openai Date: Thu, 29 Jan 2026 17:17:25 -0800 Subject: [PATCH 41/70] add error messages for the go plan type (#10181) Adds support for the Go plan type Updates rate limit error messages to point to the usage page --- codex-rs/backend-client/src/client.rs | 2 +- codex-rs/core/src/auth.rs | 1 + codex-rs/core/src/error.rs | 23 +++++++++++++++---- codex-rs/core/src/token_data.rs | 33 +++++++++++++++++++++++++++ codex-rs/protocol/src/account.rs | 1 + 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index ec5fd3f61b..6fa36d1ffd 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -351,6 +351,7 @@ impl Client { fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType { match plan_type { crate::types::PlanType::Free => AccountPlanType::Free, + crate::types::PlanType::Go => AccountPlanType::Go, crate::types::PlanType::Plus => AccountPlanType::Plus, crate::types::PlanType::Pro => AccountPlanType::Pro, crate::types::PlanType::Team => AccountPlanType::Team, @@ -358,7 +359,6 @@ impl Client { crate::types::PlanType::Enterprise => AccountPlanType::Enterprise, crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu, crate::types::PlanType::Guest - | crate::types::PlanType::Go | crate::types::PlanType::FreeWorkspace | crate::types::PlanType::Quorum | crate::types::PlanType::K12 => AccountPlanType::Unknown, diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index d6cfec5506..d4520dc855 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -159,6 +159,7 @@ impl CodexAuth { pub fn account_plan_type(&self) -> Option { let map_known = |kp: &InternalKnownPlan| match kp { InternalKnownPlan::Free => AccountPlanType::Free, + InternalKnownPlan::Go => AccountPlanType::Go, InternalKnownPlan::Plus => AccountPlanType::Plus, InternalKnownPlan::Pro => AccountPlanType::Pro, InternalKnownPlan::Team => AccountPlanType::Team, diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 6c284074c9..f9f896c3b8 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -375,9 +375,11 @@ impl std::fmt::Display for UsageLimitReachedError { retry_suffix_after_or(self.resets_at.as_ref()) ) } - Some(PlanType::Known(KnownPlan::Free)) => { - "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://openai.com/chatgpt/pricing)." - .to_string() + Some(PlanType::Known(KnownPlan::Free)) | Some(PlanType::Known(KnownPlan::Go)) => { + format!( + "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://openai.com/chatgpt/pricing),{}", + retry_suffix_after_or(self.resets_at.as_ref()) + ) } Some(PlanType::Known(KnownPlan::Pro)) => format!( "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits{}", @@ -817,7 +819,20 @@ mod tests { }; assert_eq!( err.to_string(), - "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://openai.com/chatgpt/pricing)." + "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://openai.com/chatgpt/pricing), or try again later." + ); + } + + #[test] + fn usage_limit_reached_error_formats_go_plan() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Go)), + resets_at: None, + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://openai.com/chatgpt/pricing), or try again later." ); } diff --git a/codex-rs/core/src/token_data.rs b/codex-rs/core/src/token_data.rs index 2010af16f7..e38660ff5a 100644 --- a/codex-rs/core/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -64,6 +64,7 @@ pub(crate) enum PlanType { #[serde(rename_all = "lowercase")] pub(crate) enum KnownPlan { Free, + Go, Plus, Pro, Team, @@ -195,6 +196,38 @@ mod tests { assert_eq!(info.get_chatgpt_plan_type().as_deref(), Some("Pro")); } + #[test] + fn id_token_info_parses_go_plan() { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_plan_type": "go" + } + }); + + fn b64url_no_pad(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + } + + let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64url_no_pad(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_id_token(&fake_jwt).expect("should parse"); + assert_eq!(info.email.as_deref(), Some("user@example.com")); + assert_eq!(info.get_chatgpt_plan_type().as_deref(), Some("Go")); + } + #[test] fn id_token_info_handles_missing_fields() { #[derive(Serialize)] diff --git a/codex-rs/protocol/src/account.rs b/codex-rs/protocol/src/account.rs index fb707c3a73..1cb58a020f 100644 --- a/codex-rs/protocol/src/account.rs +++ b/codex-rs/protocol/src/account.rs @@ -9,6 +9,7 @@ use ts_rs::TS; pub enum PlanType { #[default] Free, + Go, Plus, Pro, Team, From 36f2fe8af94bf678f6653e02bd5e6cd7a558aa21 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Thu, 29 Jan 2026 18:12:46 -0800 Subject: [PATCH 42/70] feat(tui): route employee feedback follow-ups to internal link (#10198) ## Problem OpenAI employees were sent to the public GitHub issue flow after `/feedback`, which is the wrong follow-up path internally. ## Mental model After feedback upload completes, we render a follow-up link/message. That link should be audience-aware but must not change the upload pipeline itself. ## Non-goals - Changing how feedback is captured or uploaded - Changing external user behavior ## Tradeoffs We detect employees via the authenticated account email suffix (`@openai.com`). If the email is unavailable (e.g., API key auth), we default to the external behavior. ## Architecture - Introduce `FeedbackAudience` and thread it from `App` -> `ChatWidget` -> `FeedbackNoteView` - Gate internal messaging/links on `FeedbackAudience::OpenAiEmployee` - Internal follow-up link is now `http://go/codex-feedback-internal` - External GitHub URL remains byte-for-byte identical ## Observability No new telemetry; this only changes rendered follow-up instructions. ## Tests - `just fmt` - `cargo test -p codex-tui --lib` --- codex-rs/tui/src/app.rs | 21 +++ codex-rs/tui/src/bottom_pane/feedback_view.rs | 150 ++++++++++++++---- codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget.rs | 10 ++ codex-rs/tui/src/chatwidget/tests.rs | 5 + 5 files changed, 154 insertions(+), 33 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ae1dc4b322..fe33df8378 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -7,6 +7,7 @@ use crate::app_event::WindowsSandboxEnableMode; use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; @@ -541,6 +542,7 @@ pub(crate) struct App { /// transcript cells. pub(crate) backtrack_render_pending: bool, pub(crate) feedback: codex_feedback::CodexFeedback, + feedback_audience: FeedbackAudience, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -599,6 +601,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, + feedback_audience: self.feedback_audience, model: Some(self.chat_widget.current_model().to_string()), otel_manager: self.otel_manager.clone(), } @@ -957,6 +960,17 @@ impl App { let auth = auth_manager.auth().await; let auth_ref = auth.as_ref(); + // Determine who should see internal Slack routing. We treat + // `@openai.com` emails as employees and default to `External` when the + // email is unavailable (for example, API key auth). + let feedback_audience = if auth_ref + .and_then(CodexAuth::get_account_email) + .is_some_and(|email| email.ends_with("@openai.com")) + { + FeedbackAudience::OpenAiEmployee + } else { + FeedbackAudience::External + }; let otel_manager = OtelManager::new( ThreadId::new(), model.as_str(), @@ -987,6 +1001,7 @@ impl App { models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, + feedback_audience, model: Some(model.clone()), otel_manager: otel_manager.clone(), }; @@ -1015,6 +1030,7 @@ impl App { models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, + feedback_audience, model: config.model.clone(), otel_manager: otel_manager.clone(), }; @@ -1043,6 +1059,7 @@ impl App { models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, + feedback_audience, model: config.model.clone(), otel_manager: otel_manager.clone(), }; @@ -1078,6 +1095,7 @@ impl App { backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: feedback.clone(), + feedback_audience, pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), @@ -1268,6 +1286,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, + feedback_audience: self.feedback_audience, model: Some(model), otel_manager: self.otel_manager.clone(), }; @@ -2601,6 +2620,7 @@ mod tests { backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), + feedback_audience: FeedbackAudience::External, pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), @@ -2653,6 +2673,7 @@ mod tests { backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), + feedback_audience: FeedbackAudience::External, pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 8fef8e79a1..a76d73475e 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -29,6 +29,18 @@ use super::textarea::TextAreaState; const BASE_BUG_ISSUE_URL: &str = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml"; +/// Internal routing link for employee feedback follow-ups. This must not be shown to external users. +const CODEX_FEEDBACK_INTERNAL_URL: &str = "http://go/codex-feedback-internal"; + +/// The target audience for feedback follow-up instructions. +/// +/// This is used strictly for messaging/links after feedback upload completes. It +/// must not change feedback upload behavior itself. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FeedbackAudience { + OpenAiEmployee, + External, +} /// Minimal input overlay to collect an optional feedback note, then upload /// both logs and rollout with classification + metadata. @@ -38,6 +50,7 @@ pub(crate) struct FeedbackNoteView { rollout_path: Option, app_event_tx: AppEventSender, include_logs: bool, + feedback_audience: FeedbackAudience, // UI state textarea: TextArea, @@ -52,6 +65,7 @@ impl FeedbackNoteView { rollout_path: Option, app_event_tx: AppEventSender, include_logs: bool, + feedback_audience: FeedbackAudience, ) -> Self { Self { category, @@ -59,6 +73,7 @@ impl FeedbackNoteView { rollout_path, app_event_tx, include_logs, + feedback_audience, textarea: TextArea::new(), textarea_state: RefCell::new(TextAreaState::default()), complete: false, @@ -96,30 +111,49 @@ impl FeedbackNoteView { } else { "• Feedback recorded (no logs)." }; - let issue_url = issue_url_for_category(self.category, &thread_id); + let issue_url = + issue_url_for_category(self.category, &thread_id, self.feedback_audience); let mut lines = vec![Line::from(match issue_url.as_ref() { + Some(_) if self.feedback_audience == FeedbackAudience::OpenAiEmployee => { + format!("{prefix} Please report this in #codex-feedback:") + } Some(_) => format!("{prefix} Please open an issue using the following URL:"), None => format!("{prefix} Thanks for the feedback!"), })]; - if let Some(url) = issue_url { - lines.extend([ - "".into(), - Line::from(vec![" ".into(), url.cyan().underlined()]), - "".into(), - Line::from(vec![ - " Or mention your thread ID ".into(), - std::mem::take(&mut thread_id).bold(), - " in an existing issue.".into(), - ]), - ]); - } else { - lines.extend([ - "".into(), - Line::from(vec![ - " Thread ID: ".into(), - std::mem::take(&mut thread_id).bold(), - ]), - ]); + match issue_url { + Some(url) if self.feedback_audience == FeedbackAudience::OpenAiEmployee => { + lines.extend([ + "".into(), + Line::from(vec![" ".into(), url.cyan().underlined()]), + "".into(), + Line::from(" Share this and add some info about your problem:"), + Line::from(vec![ + " ".into(), + format!("go/codex-feedback/{thread_id}").bold(), + ]), + ]); + } + Some(url) => { + lines.extend([ + "".into(), + Line::from(vec![" ".into(), url.cyan().underlined()]), + "".into(), + Line::from(vec![ + " Or mention your thread ID ".into(), + std::mem::take(&mut thread_id).bold(), + " in an existing issue.".into(), + ]), + ]); + } + None => { + lines.extend([ + "".into(), + Line::from(vec![ + " Thread ID: ".into(), + std::mem::take(&mut thread_id).bold(), + ]), + ]); + } } self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::PlainHistoryCell::new(lines), @@ -335,15 +369,35 @@ fn feedback_classification(category: FeedbackCategory) -> &'static str { } } -fn issue_url_for_category(category: FeedbackCategory, thread_id: &str) -> Option { +fn issue_url_for_category( + category: FeedbackCategory, + thread_id: &str, + feedback_audience: FeedbackAudience, +) -> Option { + // Only certain categories provide a follow-up link. We intentionally keep + // the external GitHub behavior identical while routing internal users to + // the internal go link. match category { - FeedbackCategory::Bug | FeedbackCategory::BadResult | FeedbackCategory::Other => Some( - format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}"), - ), + FeedbackCategory::Bug | FeedbackCategory::BadResult | FeedbackCategory::Other => { + Some(match feedback_audience { + FeedbackAudience::OpenAiEmployee => slack_feedback_url(thread_id), + FeedbackAudience::External => { + format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}") + } + }) + } FeedbackCategory::GoodResult => None, } } +/// Build the internal follow-up URL. +/// +/// We accept a `thread_id` so the call site stays symmetric with the external +/// path, but we currently point to a fixed channel without prefilling text. +fn slack_feedback_url(_thread_id: &str) -> String { + CODEX_FEEDBACK_INTERNAL_URL.to_string() +} + // Build the selection popup params for feedback categories. pub(crate) fn feedback_selection_params( app_event_tx: AppEventSender, @@ -523,7 +577,14 @@ mod tests { let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let snapshot = codex_feedback::CodexFeedback::new().snapshot(None); - FeedbackNoteView::new(category, snapshot, None, tx, true) + FeedbackNoteView::new( + category, + snapshot, + None, + tx, + true, + FeedbackAudience::External, + ) } #[test] @@ -556,19 +617,42 @@ mod tests { #[test] fn issue_url_available_for_bug_bad_result_and_other() { - let bug_url = issue_url_for_category(FeedbackCategory::Bug, "thread-1"); - assert!( - bug_url - .as_deref() - .is_some_and(|url| url.contains("template=2-bug-report")) + let bug_url = issue_url_for_category( + FeedbackCategory::Bug, + "thread-1", + FeedbackAudience::OpenAiEmployee, ); + let expected_slack_url = "http://go/codex-feedback-internal".to_string(); + assert_eq!(bug_url.as_deref(), Some(expected_slack_url.as_str())); - let bad_result_url = issue_url_for_category(FeedbackCategory::BadResult, "thread-2"); + let bad_result_url = issue_url_for_category( + FeedbackCategory::BadResult, + "thread-2", + FeedbackAudience::OpenAiEmployee, + ); assert!(bad_result_url.is_some()); - let other_url = issue_url_for_category(FeedbackCategory::Other, "thread-3"); + let other_url = issue_url_for_category( + FeedbackCategory::Other, + "thread-3", + FeedbackAudience::OpenAiEmployee, + ); assert!(other_url.is_some()); - assert!(issue_url_for_category(FeedbackCategory::GoodResult, "t").is_none()); + assert!( + issue_url_for_category( + FeedbackCategory::GoodResult, + "t", + FeedbackAudience::OpenAiEmployee + ) + .is_none() + ); + let bug_url_non_employee = + issue_url_for_category(FeedbackCategory::Bug, "t", FeedbackAudience::External); + let expected_external_url = format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20t"); + assert_eq!( + bug_url_non_employee.as_deref(), + Some(expected_external_url.as_str()) + ); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d08ccf5068..a632fd4468 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -67,6 +67,7 @@ mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; pub(crate) use list_selection_view::SelectionViewParams; mod feedback_view; +pub(crate) use feedback_view::FeedbackAudience; pub(crate) use feedback_view::feedback_disabled_params; pub(crate) use feedback_view::feedback_selection_params; pub(crate) use feedback_view::feedback_upload_consent_params; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 74200e470a..2833d4863e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -147,6 +147,7 @@ use crate::bottom_pane::CollaborationModeIndicator; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::ExperimentalFeatureItem; use crate::bottom_pane::ExperimentalFeaturesView; +use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::InputResult; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; @@ -379,6 +380,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) models_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, + pub(crate) feedback_audience: FeedbackAudience, pub(crate) model: Option, pub(crate) otel_manager: OtelManager, } @@ -558,6 +560,7 @@ pub(crate) struct ChatWidget { last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback feedback: codex_feedback::CodexFeedback, + feedback_audience: FeedbackAudience, // Current session rollout path (if known) current_rollout_path: Option, external_editor_state: ExternalEditorState, @@ -845,6 +848,7 @@ impl ChatWidget { rollout, self.app_event_tx.clone(), include_logs, + self.feedback_audience, ); self.bottom_pane.show_view(Box::new(view)); self.request_redraw(); @@ -2047,6 +2051,7 @@ impl ChatWidget { models_manager, feedback, is_first_run, + feedback_audience, model, otel_manager, } = common; @@ -2143,6 +2148,7 @@ impl ChatWidget { last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback, + feedback_audience, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, }; @@ -2186,6 +2192,7 @@ impl ChatWidget { models_manager, feedback, is_first_run, + feedback_audience, model, otel_manager, } = common; @@ -2281,6 +2288,7 @@ impl ChatWidget { last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback, + feedback_audience, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, }; @@ -2312,6 +2320,7 @@ impl ChatWidget { auth_manager, models_manager, feedback, + feedback_audience, model, otel_manager, .. @@ -2408,6 +2417,7 @@ impl ChatWidget { last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback, + feedback_audience, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, }; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d8f22dc8e3..a0e8269b58 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -8,6 +8,7 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::LocalImageAttachment; use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; @@ -716,6 +717,7 @@ async fn helpers_are_available_and_do_not_panic() { models_manager: thread_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, + feedback_audience: FeedbackAudience::External, model: Some(resolved_model), otel_manager, }; @@ -837,6 +839,7 @@ async fn make_chatwidget_manual( last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback: codex_feedback::CodexFeedback::new(), + feedback_audience: FeedbackAudience::External, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, }; @@ -2309,6 +2312,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() { models_manager: thread_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, + feedback_audience: FeedbackAudience::External, model: Some(resolved_model.clone()), otel_manager, }; @@ -2353,6 +2357,7 @@ async fn experimental_mode_plan_applies_on_startup() { models_manager: thread_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, + feedback_audience: FeedbackAudience::External, model: Some(resolved_model.clone()), otel_manager, }; From d550fbf41afc09d7d7b5ac813aea38de07b2a73f Mon Sep 17 00:00:00 2001 From: alexsong-oai Date: Thu, 29 Jan 2026 18:44:12 -0800 Subject: [PATCH 43/70] load from yaml (#10194) --- codex-rs/core/src/skills/loader.rs | 53 +++++++++++++++++------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 581f7d0c1f..32f1b8706e 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -72,7 +72,8 @@ struct DependencyTool { } const SKILLS_FILENAME: &str = "SKILL.md"; -const SKILLS_JSON_FILENAME: &str = "SKILL.json"; +const SKILLS_METADATA_DIR: &str = "agents"; +const SKILLS_METADATA_FILENAME: &str = "openai.yaml"; const SKILLS_DIR_NAME: &str = "skills"; const MAX_NAME_LEN: usize = 64; const MAX_DESCRIPTION_LEN: usize = 1024; @@ -402,7 +403,9 @@ fn load_skill_metadata(skill_path: &Path) -> (Option, Option (Option, Option parsed, Err(error) => { tracing::warn!( "ignoring {path}: invalid {label}: {error}", path = metadata_path.display(), - label = SKILLS_JSON_FILENAME + label = SKILLS_METADATA_FILENAME ); return (None, None); } @@ -859,25 +862,29 @@ mod tests { path } - fn write_skill_metadata_at(skill_dir: &Path, filename: &str, contents: &str) -> PathBuf { - let path = skill_dir.join(filename); + fn write_skill_metadata_at(skill_dir: &Path, contents: &str) -> PathBuf { + let path = skill_dir + .join(SKILLS_METADATA_DIR) + .join(SKILLS_METADATA_FILENAME); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } fs::write(&path, contents).unwrap(); path } fn write_skill_interface_at(skill_dir: &Path, contents: &str) -> PathBuf { - write_skill_metadata_at(skill_dir, SKILLS_JSON_FILENAME, contents) + write_skill_metadata_at(skill_dir, contents) } #[tokio::test] - async fn loads_skill_dependencies_metadata_from_json() { + async fn loads_skill_dependencies_metadata_from_yaml() { let codex_home = tempfile::tempdir().expect("tempdir"); let skill_path = write_skill(&codex_home, "demo", "dep-skill", "from json"); let skill_dir = skill_path.parent().expect("skill dir"); write_skill_metadata_at( skill_dir, - SKILLS_JSON_FILENAME, r#" { "dependencies": { @@ -970,7 +977,7 @@ mod tests { } #[tokio::test] - async fn loads_skill_interface_metadata_from_json() { + async fn loads_skill_interface_metadata_from_yaml() { let codex_home = tempfile::tempdir().expect("tempdir"); let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); let skill_dir = skill_path.parent().expect("skill dir"); @@ -979,16 +986,13 @@ mod tests { write_skill_interface_at( skill_dir, r##" -{ - "interface": { - "display_name": "UI Skill", - "short_description": " short desc ", - "icon_small": "./assets/small-400px.png", - "icon_large": "./assets/large-logo.svg", - "brand_color": "#3B82F6", - "default_prompt": " default prompt " - } -} +interface: + display_name: "UI Skill" + short_description: " short desc " + icon_small: "./assets/small-400px.png" + icon_large: "./assets/large-logo.svg" + brand_color: "#3B82F6" + default_prompt: " default prompt " "##, ); @@ -1000,8 +1004,13 @@ mod tests { "unexpected errors: {:?}", outcome.errors ); + let user_skills: Vec = outcome + .skills + .into_iter() + .filter(|skill| skill.scope == SkillScope::User) + .collect(); assert_eq!( - outcome.skills, + user_skills, vec![SkillMetadata { name: "ui-skill".to_string(), description: "from json".to_string(), From e3ab0bd9739106b039ebffe040914d4aac0c6de6 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 30 Jan 2026 00:10:12 -0700 Subject: [PATCH 44/70] chore(personality) new schema with fallbacks (#10147) ## Summary Let's dial in this api contract in a bit more with more robust fallback behavior when model_instructions_template is false. Switches to a more explicit template / variables structure, with more fallbacks. ## Testing - [x] Adding unit tests - [x] Tested locally --- .../app-server/tests/common/models_cache.rs | 2 +- .../tests/suite/v2/thread_resume.rs | 13 +- .../app-server/tests/suite/v2/turn_start.rs | 9 +- .../codex-api/tests/models_integration.rs | 2 +- codex-rs/core/src/codex.rs | 52 ++- .../core/src/models_manager/model_info.rs | 55 +-- .../gpt-5.2-codex_instructions_template.md | 4 +- ...{friendly.md => gpt-5.2-codex_friendly.md} | 0 ...ragmatic.md => gpt-5.2-codex_pragmatic.md} | 1 + codex-rs/core/tests/suite/models_cache_ttl.rs | 2 +- codex-rs/core/tests/suite/personality.rs | 360 +++++++++++++++++- codex-rs/core/tests/suite/remote_models.rs | 6 +- codex-rs/protocol/src/openai_models.rs | 216 +++++++++-- 13 files changed, 607 insertions(+), 115 deletions(-) rename codex-rs/core/templates/personalities/{friendly.md => gpt-5.2-codex_friendly.md} (100%) rename codex-rs/core/templates/personalities/{pragmatic.md => gpt-5.2-codex_pragmatic.md} (99%) diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 7a093dc7f0..59fddc2427 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -27,7 +27,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { priority, upgrade: preset.upgrade.as_ref().map(|u| u.into()), base_instructions: "base instructions".to_string(), - model_instructions_template: None, + model_messages: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index af746b37b7..6ad0a14b30 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -31,7 +31,7 @@ use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -const DEFAULT_BASE_INSTRUCTIONS: &str = "You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer."; +const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals."; #[tokio::test] async fn thread_resume_returns_original_thread() -> Result<()> { @@ -368,7 +368,7 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { } #[tokio::test] -async fn thread_resume_accepts_personality_override_v2() -> Result<()> { +async fn thread_resume_accepts_personality_override() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -438,14 +438,14 @@ async fn thread_resume_accepts_personality_override_v2() -> Result<()> { let request = response_mock.single_request(); let developer_texts = request.message_input_texts("developer"); assert!( - !developer_texts + developer_texts .iter() .any(|text| text.contains("")), - "did not expect a personality update message in developer input, got {developer_texts:?}" + "expected a personality update message in developer input, got {developer_texts:?}" ); let instructions_text = request.instructions_text(); assert!( - instructions_text.contains(DEFAULT_BASE_INSTRUCTIONS), + instructions_text.contains(CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT), "expected default base instructions from history, got {instructions_text:?}" ); @@ -459,7 +459,7 @@ fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io config_toml, format!( r#" -model = "mock-model" +model = "gpt-5.2-codex" approval_policy = "never" sandbox_mode = "read-only" @@ -467,6 +467,7 @@ model_provider = "mock_provider" [features] remote_models = false +personality = true [model_providers.mock_provider] name = "Mock provider for test" diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 3abe73e2e9..99eff9ecb2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -63,7 +63,7 @@ async fn turn_start_sends_originator_header() -> Result<()> { codex_home.path(), &server.uri(), "never", - &BTreeMap::default(), + &BTreeMap::from([(Feature::Personality, true)]), )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -138,7 +138,7 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { codex_home.path(), &server.uri(), "never", - &BTreeMap::default(), + &BTreeMap::from([(Feature::Personality, true)]), )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -230,7 +230,7 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( codex_home.path(), &server.uri(), "never", - &BTreeMap::default(), + &BTreeMap::from([(Feature::Personality, true)]), )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -425,7 +425,7 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> { codex_home.path(), &server.uri(), "never", - &BTreeMap::default(), + &BTreeMap::from([(Feature::Personality, true)]), )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -473,6 +473,7 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> { if developer_texts.is_empty() { eprintln!("request body: {}", request.body_json()); } + assert!( developer_texts .iter() diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index b1c4f060b2..c75c46a28a 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -77,7 +77,7 @@ async fn models_client_hits_models_endpoint() { priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), - model_instructions_template: None, + model_messages: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1fb5572183..250ea1ff49 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1285,27 +1285,31 @@ impl Session { previous: Option<&Arc>, next: &TurnContext, ) -> Option { - let personality = next.personality?; - if let Some(prev) = previous - && prev.personality == Some(personality) - { + if !self.features.enabled(Feature::Personality) { return None; } - let model_info = next.client.get_model_info(); - let personality_message = Self::personality_message_for(&model_info, personality); + let previous = previous?; - personality_message.map(|personality_message| { - DeveloperInstructions::personality_spec_message(personality_message).into() - }) + // if a personality is specified and it's different from the previous one, build a personality update item + if let Some(personality) = next.personality + && next.personality != previous.personality + { + let model_info = next.client.get_model_info(); + let personality_message = Self::personality_message_for(&model_info, personality); + personality_message.map(|personality_message| { + DeveloperInstructions::personality_spec_message(personality_message).into() + }) + } else { + None + } } fn personality_message_for(model_info: &ModelInfo, personality: Personality) -> Option { model_info - .model_instructions_template + .model_messages .as_ref() - .and_then(|template| template.personality_messages.as_ref()) - .and_then(|messages| messages.0.get(&personality)) - .cloned() + .and_then(|spec| spec.get_personality_message(Some(personality))) + .filter(|message| !message.is_empty()) } fn build_collaboration_mode_update_item( @@ -1845,15 +1849,33 @@ impl Session { items.push(DeveloperInstructions::new(developer_instructions.to_string()).into()); } // Add developer instructions from collaboration_mode if they exist and are non-empty - let collaboration_mode = { + let (collaboration_mode, base_instructions) = { let state = self.state.lock().await; - state.session_configuration.collaboration_mode.clone() + ( + state.session_configuration.collaboration_mode.clone(), + state.session_configuration.base_instructions.clone(), + ) }; if let Some(collab_instructions) = DeveloperInstructions::from_collaboration_mode(&collaboration_mode) { items.push(collab_instructions.into()); } + if self.features.enabled(Feature::Personality) + && let Some(personality) = turn_context.personality + { + let model_info = turn_context.client.get_model_info(); + let has_baked_personality = model_info.supports_personality() + && base_instructions == model_info.get_model_instructions(Some(personality)); + if !has_baked_personality + && let Some(personality_message) = + Self::personality_message_for(&model_info, personality) + { + items.push( + DeveloperInstructions::personality_spec_message(personality_message).into(), + ); + } + } if let Some(user_instructions) = turn_context.user_instructions.as_deref() { items.push( UserInstructions { diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index b50f23274a..eaab160001 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -1,19 +1,17 @@ -use std::collections::BTreeMap; - -use codex_protocol::config_types::Personality; use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ModelInstructionsTemplate; +use codex_protocol::openai_models::ModelInstructionsVariables; +use codex_protocol::openai_models::ModelMessages; use codex_protocol::openai_models::ModelVisibility; -use codex_protocol::openai_models::PersonalityMessages; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationMode; use codex_protocol::openai_models::TruncationPolicyConfig; use crate::config::Config; +use crate::features::Feature; use crate::truncate::approx_bytes_for_tokens; use tracing::warn; @@ -29,8 +27,11 @@ const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../../gpt-5.1-codex-m const GPT_5_2_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt-5.2-codex_prompt.md"); const GPT_5_2_CODEX_INSTRUCTIONS_TEMPLATE: &str = include_str!("../../templates/model_instructions/gpt-5.2-codex_instructions_template.md"); -const PERSONALITY_FRIENDLY: &str = include_str!("../../templates/personalities/friendly.md"); -const PERSONALITY_PRAGMATIC: &str = include_str!("../../templates/personalities/pragmatic.md"); + +const GPT_5_2_CODEX_PERSONALITY_FRIENDLY: &str = + include_str!("../../templates/personalities/gpt-5.2-codex_friendly.md"); +const GPT_5_2_CODEX_PERSONALITY_PRAGMATIC: &str = + include_str!("../../templates/personalities/gpt-5.2-codex_pragmatic.md"); pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; @@ -54,7 +55,7 @@ macro_rules! model_info { priority: 99, upgrade: None, base_instructions: BASE_INSTRUCTIONS.to_string(), - model_instructions_template: None, + model_messages: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, @@ -100,8 +101,11 @@ pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> Mo if let Some(base_instructions) = &config.base_instructions { model.base_instructions = base_instructions.clone(); - model.model_instructions_template = None; + model.model_messages = None; + } else if !config.features.enabled(Feature::Personality) { + model.model_messages = None; } + model } @@ -169,15 +173,13 @@ pub(crate) fn find_model_info_for_slug(slug: &str) -> ModelInfo { model_info!( slug, base_instructions: GPT_5_2_CODEX_INSTRUCTIONS.to_string(), - model_instructions_template: Some(ModelInstructionsTemplate { - template: GPT_5_2_CODEX_INSTRUCTIONS_TEMPLATE.to_string(), - personality_messages: Some(PersonalityMessages(BTreeMap::from([( - Personality::Friendly, - PERSONALITY_FRIENDLY.to_string(), - ), ( - Personality::Pragmatic, - PERSONALITY_PRAGMATIC.to_string(), - )]))), + model_messages: Some(ModelMessages { + instructions_template: Some(GPT_5_2_CODEX_INSTRUCTIONS_TEMPLATE.to_string()), + instructions_variables: Some(ModelInstructionsVariables { + personality_default: Some("".to_string()), + personality_friendly: Some(GPT_5_2_CODEX_PERSONALITY_FRIENDLY.to_string()), + personality_pragmatic: Some(GPT_5_2_CODEX_PERSONALITY_PRAGMATIC.to_string()), + }), }), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), shell_type: ConfigShellToolType::ShellCommand, @@ -213,15 +215,14 @@ pub(crate) fn find_model_info_for_slug(slug: &str) -> ModelInfo { truncation_policy: TruncationPolicyConfig::tokens(10_000), context_window: Some(CONTEXT_WINDOW_272K), supported_reasoning_levels: supported_reasoning_level_low_medium_high_xhigh(), - model_instructions_template: Some(ModelInstructionsTemplate { - template: GPT_5_2_CODEX_INSTRUCTIONS_TEMPLATE.to_string(), - personality_messages: Some(PersonalityMessages(BTreeMap::from([( - Personality::Friendly, - PERSONALITY_FRIENDLY.to_string(), - ), ( - Personality::Pragmatic, - PERSONALITY_PRAGMATIC.to_string(), - )]))), + base_instructions: GPT_5_2_CODEX_INSTRUCTIONS.to_string(), + model_messages: Some(ModelMessages { + instructions_template: Some(GPT_5_2_CODEX_INSTRUCTIONS_TEMPLATE.to_string()), + instructions_variables: Some(ModelInstructionsVariables { + personality_default: Some("".to_string()), + personality_friendly: Some(GPT_5_2_CODEX_PERSONALITY_FRIENDLY.to_string()), + personality_pragmatic: Some(GPT_5_2_CODEX_PERSONALITY_PRAGMATIC.to_string()), + }), }), ) } else if slug.starts_with("gpt-5.1-codex-max") { diff --git a/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md b/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md index 6e9e53e361..530e825d5e 100644 --- a/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md +++ b/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md @@ -1,8 +1,6 @@ You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals. -# Personality - -{{ personality_message }} +{{ personality }} ## Tone and style - Anything you say outside of tool use is shown to the user. Do not narrate abstractly; explain what you are doing and why, using plain language. diff --git a/codex-rs/core/templates/personalities/friendly.md b/codex-rs/core/templates/personalities/gpt-5.2-codex_friendly.md similarity index 100% rename from codex-rs/core/templates/personalities/friendly.md rename to codex-rs/core/templates/personalities/gpt-5.2-codex_friendly.md diff --git a/codex-rs/core/templates/personalities/pragmatic.md b/codex-rs/core/templates/personalities/gpt-5.2-codex_pragmatic.md similarity index 99% rename from codex-rs/core/templates/personalities/pragmatic.md rename to codex-rs/core/templates/personalities/gpt-5.2-codex_pragmatic.md index 0fd76898a6..ca1738e42a 100644 --- a/codex-rs/core/templates/personalities/pragmatic.md +++ b/codex-rs/core/templates/personalities/gpt-5.2-codex_pragmatic.md @@ -1,4 +1,5 @@ # Personality + You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration is a kind of quiet joy: as real progress happens, your enthusiasm shows briefly and specifically. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. ## Values diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 69506cffc5..5756f46a69 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -175,7 +175,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { priority, upgrade: None, base_instructions: "base instructions".to_string(), - model_instructions_template: None, + model_messages: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 7a27866569..79e0d36c68 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -9,10 +9,10 @@ use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ModelInstructionsTemplate; +use codex_protocol::openai_models::ModelInstructionsVariables; +use codex_protocol::openai_models::ModelMessages; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; -use codex_protocol::openai_models::PersonalityMessages; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; @@ -29,7 +29,6 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; -use std::collections::BTreeMap; use std::sync::Arc; use tempfile::TempDir; use tokio::time::Duration; @@ -49,6 +48,7 @@ fn sse_completed(id: &str) -> String { async fn model_personality_does_not_mutate_base_instructions_without_template() { let codex_home = TempDir::new().expect("create temp dir"); let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::Personality); config.model_personality = Some(Personality::Friendly); let model_info = ModelsManager::construct_model_info_offline("gpt-5.1", &config); @@ -62,6 +62,7 @@ async fn model_personality_does_not_mutate_base_instructions_without_template() async fn base_instructions_override_disables_personality_template() { let codex_home = TempDir::new().expect("create temp dir"); let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::Personality); config.model_personality = Some(Personality::Friendly); config.base_instructions = Some("override instructions".to_string()); @@ -80,7 +81,12 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res let server = start_mock_server().await; let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; - let mut builder = test_codex().with_model("gpt-5.2-codex"); + let mut builder = test_codex() + .with_model("gpt-5.2-codex") + .with_config(|config| { + config.features.disable(Feature::RemoteModels); + config.features.enable(Feature::Personality); + }); let test = builder.build(&server).await?; test.codex @@ -122,10 +128,11 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< let server = start_mock_server().await; let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; let mut builder = test_codex() - .with_model("exp-codex-personality") + .with_model("gpt-5.2-codex") .with_config(|config| { - config.model_personality = Some(Personality::Friendly); config.features.disable(Feature::RemoteModels); + config.features.enable(Feature::Personality); + config.model_personality = Some(Personality::Friendly); }); let test = builder.build(&server).await?; @@ -182,6 +189,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> .with_model("exp-codex-personality") .with_config(|config| { config.features.disable(Feature::RemoteModels); + config.features.enable(Feature::Personality); }); let test = builder.build(&server).await?; @@ -263,6 +271,330 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn instructions_uses_base_if_feature_disabled() -> anyhow::Result<()> { + let codex_home = TempDir::new().expect("create temp dir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.features.disable(Feature::Personality); + config.model_personality = Some(Personality::Friendly); + + let model_info = ModelsManager::construct_model_info_offline("gpt-5.2-codex", &config); + assert_eq!( + model_info.get_model_instructions(config.model_personality), + model_info.base_instructions + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let resp_mock = mount_sse_sequence( + &server, + vec![sse_completed("resp-1"), sse_completed("resp-2")], + ) + .await; + let mut builder = test_codex() + .with_model("exp-codex-personality") + .with_config(|config| { + config.features.disable(Feature::RemoteModels); + config.features.disable(Feature::Personality); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: test.config.approval_policy.value(), + sandbox_policy: SandboxPolicy::ReadOnly, + model: test.session_configured.model.clone(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + collaboration_mode: None, + personality: Some(Personality::Friendly), + }) + .await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: test.config.approval_policy.value(), + sandbox_policy: SandboxPolicy::ReadOnly, + model: test.session_configured.model.clone(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = resp_mock.requests(); + assert_eq!(requests.len(), 2, "expected two requests"); + let request = requests + .last() + .expect("expected personality update request"); + + let developer_texts = request.message_input_texts("developer"); + let personality_text = developer_texts + .iter() + .find(|text| text.contains("")); + assert!( + personality_text.is_none(), + "expected no personality preamble, got {personality_text:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ignores_remote_model_personality_if_remote_models_disabled() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await; + + let remote_slug = "gpt-5.2-codex"; + let remote_personality_message = "Friendly from remote template"; + let remote_model = ModelInfo { + slug: remote_slug.to_string(), + display_name: "Remote personality test".to_string(), + description: Some("Remote model with personality template".to_string()), + default_reasoning_level: Some(ReasoningEffort::Medium), + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::UnifiedExec, + visibility: ModelVisibility::List, + supported_in_api: true, + priority: 1, + upgrade: None, + base_instructions: "base instructions".to_string(), + model_messages: Some(ModelMessages { + instructions_template: Some( + "Base instructions\n{{ personality_message }}\n".to_string(), + ), + instructions_variables: Some(ModelInstructionsVariables { + personality_default: None, + personality_friendly: Some(remote_personality_message.to_string()), + personality_pragmatic: None, + }), + }), + supports_reasoning_summaries: false, + support_verbosity: false, + default_verbosity: None, + apply_patch_tool_type: None, + truncation_policy: TruncationPolicyConfig::bytes(10_000), + supports_parallel_tool_calls: false, + context_window: Some(128_000), + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: Vec::new(), + }; + + let _models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + + let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; + + let mut builder = test_codex() + .with_auth(codex_core::CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.features.disable(Feature::RemoteModels); + config.features.enable(Feature::Personality); + config.model = Some(remote_slug.to_string()); + config.model_personality = Some(Personality::Friendly); + }); + let test = builder.build(&server).await?; + + wait_for_model_available( + &test.thread_manager.get_models_manager(), + remote_slug, + &test.config, + ) + .await; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + model: remote_slug.to_string(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = resp_mock.single_request(); + let instructions_text = request.instructions_text(); + + assert!( + instructions_text.contains("You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals."), + "expected instructions to use the template instructions, got: {instructions_text:?}" + ); + assert!( + instructions_text.contains( + "You optimize for team morale and being a supportive teammate as much as code quality." + ), + "expected instructions to include the local friendly personality template, got: {instructions_text:?}" + ); + assert!( + !instructions_text.contains("{{ personality_message }}"), + "expected legacy personality placeholder to be replaced, got: {instructions_text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_model_default_personality_instructions_with_feature() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await; + + let remote_slug = "codex-remote-default-personality"; + let default_personality_message = "Default from remote template"; + let remote_model = ModelInfo { + slug: remote_slug.to_string(), + display_name: "Remote default personality test".to_string(), + description: Some("Remote model with default personality template".to_string()), + default_reasoning_level: Some(ReasoningEffort::Medium), + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::UnifiedExec, + visibility: ModelVisibility::List, + supported_in_api: true, + priority: 1, + upgrade: None, + base_instructions: "base instructions".to_string(), + model_messages: Some(ModelMessages { + instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()), + instructions_variables: Some(ModelInstructionsVariables { + personality_default: Some(default_personality_message.to_string()), + personality_friendly: Some("Friendly variant".to_string()), + personality_pragmatic: Some("Pragmatic variant".to_string()), + }), + }), + supports_reasoning_summaries: false, + support_verbosity: false, + default_verbosity: None, + apply_patch_tool_type: None, + truncation_policy: TruncationPolicyConfig::bytes(10_000), + supports_parallel_tool_calls: false, + context_window: Some(128_000), + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: Vec::new(), + }; + + let _models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + + let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; + + let mut builder = test_codex() + .with_auth(codex_core::CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.features.enable(Feature::RemoteModels); + config.features.enable(Feature::Personality); + config.model = Some(remote_slug.to_string()); + }); + let test = builder.build(&server).await?; + + wait_for_model_available( + &test.thread_manager.get_models_manager(), + remote_slug, + &test.config, + ) + .await; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + model: remote_slug.to_string(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = resp_mock.single_request(); + let instructions_text = request.instructions_text(); + + assert!( + instructions_text.contains(default_personality_message), + "expected instructions to include the remote default personality template, got: {instructions_text:?}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_turn_personality_remote_model_template_includes_update_message() -> anyhow::Result<()> { @@ -290,12 +622,15 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), - model_instructions_template: Some(ModelInstructionsTemplate { - template: "Base instructions\n{{ personality_message }}\n".to_string(), - personality_messages: Some(PersonalityMessages(BTreeMap::from([( - Personality::Friendly, - remote_personality_message.to_string(), - )]))), + model_messages: Some(ModelMessages { + instructions_template: Some( + "Base instructions\n{{ personality_message }}\n".to_string(), + ), + instructions_variables: Some(ModelInstructionsVariables { + personality_default: None, + personality_friendly: Some(remote_personality_message.to_string()), + personality_pragmatic: None, + }), }), supports_reasoning_summaries: false, support_verbosity: false, @@ -327,6 +662,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - .with_auth(codex_core::CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { config.features.enable(Feature::RemoteModels); + config.features.enable(Feature::Personality); config.model = Some("gpt-5.2-codex".to_string()); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 71374cb84f..f59f73fd14 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -79,7 +79,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), - model_instructions_template: None, + model_messages: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, @@ -316,7 +316,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { priority: 1, upgrade: None, base_instructions: remote_base.to_string(), - model_instructions_template: None, + model_messages: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, @@ -790,7 +790,7 @@ fn test_remote_model_with_policy( priority, upgrade: None, base_instructions: "base instructions".to_string(), - model_instructions_template: None, + model_messages: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 3f29484ffd..9a940539d5 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -1,4 +1,3 @@ -use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; @@ -14,7 +13,7 @@ use ts_rs::TS; use crate::config_types::Personality; use crate::config_types::Verbosity; -const PERSONALITY_PLACEHOLDER: &str = "{{ personality_message }}"; +const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning #[derive( @@ -189,7 +188,7 @@ pub struct ModelInfo { pub upgrade: Option, pub base_instructions: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_instructions_template: Option, + pub model_messages: Option, pub supports_reasoning_summaries: bool, pub support_verbosity: bool, pub default_verbosity: Option, @@ -218,26 +217,25 @@ impl ModelInfo { } pub fn supports_personality(&self) -> bool { - self.model_instructions_template + self.model_messages .as_ref() - .is_some_and(ModelInstructionsTemplate::supports_personality) + .is_some_and(ModelMessages::supports_personality) } pub fn get_model_instructions(&self, personality: Option) -> String { - if let Some(personality) = personality - && let Some(template) = &self.model_instructions_template - && template.has_personality_placeholder() - && let Some(personality_messages) = &template.personality_messages - && let Some(personality_message) = personality_messages.0.get(&personality) + if let Some(model_messages) = &self.model_messages + && let Some(template) = &model_messages.instructions_template { - template - .template - .replace(PERSONALITY_PLACEHOLDER, personality_message.as_str()) + // if we have a template, always use it + let personality_message = model_messages + .get_personality_message(personality) + .unwrap_or_default(); + template.replace(PERSONALITY_PLACEHOLDER, personality_message.as_str()) } else if let Some(personality) = personality { warn!( model = %self.slug, %personality, - "Model personality requested but model_instructions_template is invalid, falling back to base instructions." + "Model personality requested but model_messages is missing, falling back to base instructions." ); self.base_instructions.clone() } else { @@ -246,31 +244,62 @@ impl ModelInfo { } } -/// A strongly-typed template for assembling model instructions. If populated and valid, will override -/// base_instructions. +/// A strongly-typed template for assembling model instructions and developer messages. If +/// instructions_* is populated and valid, it will override base_instructions. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] -pub struct ModelInstructionsTemplate { - pub template: String, - pub personality_messages: Option, +pub struct ModelMessages { + pub instructions_template: Option, + pub instructions_variables: Option, } -impl ModelInstructionsTemplate { +impl ModelMessages { fn has_personality_placeholder(&self) -> bool { - self.template.contains(PERSONALITY_PLACEHOLDER) + self.instructions_template + .as_ref() + .map(|spec| spec.contains(PERSONALITY_PLACEHOLDER)) + .unwrap_or(false) } fn supports_personality(&self) -> bool { self.has_personality_placeholder() - && self.personality_messages.as_ref().is_some_and(|messages| { - Personality::iter().all(|personality| messages.0.contains_key(&personality)) - }) + && self + .instructions_variables + .as_ref() + .is_some_and(ModelInstructionsVariables::is_complete) + } + + pub fn get_personality_message(&self, personality: Option) -> Option { + self.instructions_variables + .as_ref() + .and_then(|variables| variables.get_personality_message(personality)) } } -// serializes as a dictionary from personality to message -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, TS, JsonSchema)] -#[serde(transparent)] -pub struct PersonalityMessages(pub BTreeMap); +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct ModelInstructionsVariables { + pub personality_default: Option, + pub personality_friendly: Option, + pub personality_pragmatic: Option, +} + +impl ModelInstructionsVariables { + pub fn is_complete(&self) -> bool { + self.personality_default.is_some() + && self.personality_friendly.is_some() + && self.personality_pragmatic.is_some() + } + + pub fn get_personality_message(&self, personality: Option) -> Option { + if let Some(personality) = personality { + match personality { + Personality::Friendly => self.personality_friendly.clone(), + Personality::Pragmatic => self.personality_pragmatic.clone(), + } + } else { + self.personality_default.clone() + } + } +} #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ModelInfoUpgrade { @@ -407,7 +436,7 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - fn test_model(template: Option) -> ModelInfo { + fn test_model(spec: Option) -> ModelInfo { ModelInfo { slug: "test-model".to_string(), display_name: "Test Model".to_string(), @@ -420,7 +449,7 @@ mod tests { priority: 1, upgrade: None, base_instructions: "base".to_string(), - model_instructions_template: template, + model_messages: spec, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, @@ -434,18 +463,19 @@ mod tests { } } - fn personality_messages() -> PersonalityMessages { - PersonalityMessages(BTreeMap::from([( - Personality::Friendly, - "friendly".to_string(), - )])) + fn personality_variables() -> ModelInstructionsVariables { + ModelInstructionsVariables { + personality_default: Some("default".to_string()), + personality_friendly: Some("friendly".to_string()), + personality_pragmatic: Some("pragmatic".to_string()), + } } #[test] fn get_model_instructions_uses_template_when_placeholder_present() { - let model = test_model(Some(ModelInstructionsTemplate { - template: "Hello {{ personality_message }}".to_string(), - personality_messages: Some(personality_messages()), + let model = test_model(Some(ModelMessages { + instructions_template: Some("Hello {{ personality }}".to_string()), + instructions_variables: Some(personality_variables()), })); let instructions = model.get_model_instructions(Some(Personality::Friendly)); @@ -454,14 +484,116 @@ mod tests { } #[test] - fn get_model_instructions_falls_back_when_placeholder_missing() { - let model = test_model(Some(ModelInstructionsTemplate { - template: "Hello there".to_string(), - personality_messages: Some(personality_messages()), + fn get_model_instructions_always_strips_placeholder() { + let model = test_model(Some(ModelMessages { + instructions_template: Some("Hello\n{{ personality }}".to_string()), + instructions_variables: Some(ModelInstructionsVariables { + personality_default: None, + personality_friendly: Some("friendly".to_string()), + personality_pragmatic: None, + }), + })); + assert_eq!( + model.get_model_instructions(Some(Personality::Friendly)), + "Hello\nfriendly" + ); + assert_eq!( + model.get_model_instructions(Some(Personality::Pragmatic)), + "Hello\n" + ); + assert_eq!(model.get_model_instructions(None), "Hello\n"); + + let model_no_personality = test_model(Some(ModelMessages { + instructions_template: Some("Hello\n{{ personality }}".to_string()), + instructions_variables: Some(ModelInstructionsVariables { + personality_default: None, + personality_friendly: None, + personality_pragmatic: None, + }), + })); + assert_eq!( + model_no_personality.get_model_instructions(Some(Personality::Friendly)), + "Hello\n" + ); + assert_eq!( + model_no_personality.get_model_instructions(Some(Personality::Pragmatic)), + "Hello\n" + ); + assert_eq!(model_no_personality.get_model_instructions(None), "Hello\n"); + } + + #[test] + fn get_model_instructions_falls_back_when_template_is_missing() { + let model = test_model(Some(ModelMessages { + instructions_template: None, + instructions_variables: Some(ModelInstructionsVariables { + personality_default: None, + personality_friendly: None, + personality_pragmatic: None, + }), })); let instructions = model.get_model_instructions(Some(Personality::Friendly)); assert_eq!(instructions, "base"); } + + #[test] + fn get_personality_message_returns_default_when_personality_is_none() { + let personality_template = personality_variables(); + assert_eq!( + personality_template.get_personality_message(None), + Some("default".to_string()) + ); + } + + #[test] + fn get_personality_message() { + let personality_variables = personality_variables(); + assert_eq!( + personality_variables.get_personality_message(Some(Personality::Friendly)), + Some("friendly".to_string()) + ); + assert_eq!( + personality_variables.get_personality_message(Some(Personality::Pragmatic)), + Some("pragmatic".to_string()) + ); + assert_eq!( + personality_variables.get_personality_message(None), + Some("default".to_string()) + ); + + let personality_variables = ModelInstructionsVariables { + personality_default: Some("default".to_string()), + personality_friendly: None, + personality_pragmatic: None, + }; + assert_eq!( + personality_variables.get_personality_message(Some(Personality::Friendly)), + None + ); + assert_eq!( + personality_variables.get_personality_message(Some(Personality::Pragmatic)), + None + ); + assert_eq!( + personality_variables.get_personality_message(None), + Some("default".to_string()) + ); + + let personality_variables = ModelInstructionsVariables { + personality_default: None, + personality_friendly: Some("friendly".to_string()), + personality_pragmatic: Some("pragmatic".to_string()), + }; + assert_eq!( + personality_variables.get_personality_message(Some(Personality::Friendly)), + Some("friendly".to_string()) + ); + assert_eq!( + personality_variables.get_personality_message(Some(Personality::Pragmatic)), + Some("pragmatic".to_string()) + ); + assert_eq!(personality_variables.get_personality_message(None), None); + } } From 34f89b12d038d330f1e8306dbe9e1727b32f09c0 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Thu, 29 Jan 2026 23:40:32 -0800 Subject: [PATCH 45/70] MCP tool call approval (simplified version) (#10200) Add elicitation approval request for MCP tool call requests. --- codex-rs/chatgpt/src/connectors.rs | 6 +- codex-rs/core/src/mcp_connection_manager.rs | 49 ++- codex-rs/core/src/mcp_tool_call.rs | 317 +++++++++++++++++++- codex-rs/core/src/tools/handlers/mcp.rs | 3 +- 4 files changed, 364 insertions(+), 11 deletions(-) diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index c1a8f65359..83b7271583 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -221,7 +221,11 @@ fn normalize_connector_value(value: Option<&str>) -> Option { } const ALLOWED_APPS_SDK_APPS: &[&str] = &["asdk_app_69781557cc1481919cf5e9824fa2e792"]; -const DISALLOWED_CONNECTOR_IDS: &[&str] = &["asdk_app_6938a94a61d881918ef32cb999ff937c"]; +const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ + "asdk_app_6938a94a61d881918ef32cb999ff937c", + "connector_2b0a9009c9c64bf9933a3dae3f2b1254", + "connector_68de829bf7648191acd70a907364c67c", +]; const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_"; fn filter_disallowed_connectors(connectors: Vec) -> Vec { diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 15305f4b0c..1b5048ab0c 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -769,6 +769,32 @@ fn filter_tools(tools: Vec, filter: ToolFilter) -> Vec { .collect() } +fn normalize_codex_apps_tool_title( + server_name: &str, + connector_name: Option<&str>, + value: &str, +) -> String { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return value.to_string(); + } + + let Some(connector_name) = connector_name + .map(str::trim) + .filter(|name| !name.is_empty()) + else { + return value.to_string(); + }; + + let prefix = format!("{connector_name}_"); + if let Some(stripped) = value.strip_prefix(&prefix) + && !stripped.is_empty() + { + return stripped.to_string(); + } + + value.to_string() +} + fn resolve_bearer_token( server_name: &str, bearer_token_env_var: Option<&str>, @@ -926,12 +952,23 @@ async fn list_tools_for_client( Ok(resp .tools .into_iter() - .map(|tool| ToolInfo { - server_name: server_name.to_owned(), - tool_name: tool.tool.name.clone(), - tool: tool.tool, - connector_id: tool.connector_id, - connector_name: tool.connector_name, + .map(|tool| { + let connector_name = tool.connector_name; + let mut tool_def = tool.tool; + if let Some(title) = tool_def.title.as_deref() { + let normalized_title = + normalize_codex_apps_tool_title(server_name, connector_name.as_deref(), title); + if tool_def.title.as_deref() != Some(normalized_title.as_str()) { + tool_def.title = Some(normalized_title); + } + } + ToolInfo { + server_name: server_name.to_owned(), + tool_name: tool_def.name.clone(), + tool: tool_def, + connector_id: tool.connector_id, + connector_name, + } }) .collect()) } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 6066c1512e..6425392fb5 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1,20 +1,30 @@ +use std::time::Duration; use std::time::Instant; use tracing::error; use crate::codex::Session; use crate::codex::TurnContext; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; use crate::protocol::McpToolCallEndEvent; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputQuestion; +use codex_protocol::request_user_input::RequestUserInputQuestionOption; +use codex_protocol::request_user_input::RequestUserInputResponse; +use mcp_types::ToolAnnotations; +use std::sync::Arc; /// Handles the specified tool call dispatches the appropriate /// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`. pub(crate) async fn handle_mcp_tool_call( - sess: &Session, + sess: Arc, turn_context: &TurnContext, call_id: String, server: String, @@ -48,11 +58,79 @@ pub(crate) async fn handle_mcp_tool_call( arguments: arguments_value.clone(), }; + if let Some(decision) = + maybe_request_mcp_tool_approval(sess.as_ref(), turn_context, &call_id, &server, &tool_name) + .await + { + let result = match decision { + McpToolApprovalDecision::Accept => { + let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id: call_id.clone(), + invocation: invocation.clone(), + }); + notify_mcp_tool_call_event(sess.as_ref(), turn_context, tool_call_begin_event) + .await; + + let start = Instant::now(); + let result = sess + .call_tool(&server, &tool_name, arguments_value.clone()) + .await + .map_err(|e| format!("tool call error: {e:?}")); + if let Err(e) = &result { + tracing::warn!("MCP tool call error: {e:?}"); + } + let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: call_id.clone(), + invocation, + duration: start.elapsed(), + result: result.clone(), + }); + notify_mcp_tool_call_event( + sess.as_ref(), + turn_context, + tool_call_end_event.clone(), + ) + .await; + result + } + McpToolApprovalDecision::Decline => { + let message = "user rejected MCP tool call".to_string(); + notify_mcp_tool_call_skip( + sess.as_ref(), + turn_context, + &call_id, + invocation, + message, + ) + .await + } + McpToolApprovalDecision::Cancel => { + let message = "user cancelled MCP tool call".to_string(); + notify_mcp_tool_call_skip( + sess.as_ref(), + turn_context, + &call_id, + invocation, + message, + ) + .await + } + }; + + let status = if result.is_ok() { "ok" } else { "error" }; + turn_context + .client + .get_otel_manager() + .counter("codex.mcp.call", 1, &[("status", status)]); + + return ResponseInputItem::McpToolCallOutput { call_id, result }; + } + let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: call_id.clone(), invocation: invocation.clone(), }); - notify_mcp_tool_call_event(sess, turn_context, tool_call_begin_event).await; + notify_mcp_tool_call_event(sess.as_ref(), turn_context, tool_call_begin_event).await; let start = Instant::now(); // Perform the tool call. @@ -70,7 +148,7 @@ pub(crate) async fn handle_mcp_tool_call( result: result.clone(), }); - notify_mcp_tool_call_event(sess, turn_context, tool_call_end_event.clone()).await; + notify_mcp_tool_call_event(sess.as_ref(), turn_context, tool_call_end_event.clone()).await; let status = if result.is_ok() { "ok" } else { "error" }; turn_context @@ -84,3 +162,236 @@ pub(crate) async fn handle_mcp_tool_call( async fn notify_mcp_tool_call_event(sess: &Session, turn_context: &TurnContext, event: EventMsg) { sess.send_event(turn_context, event).await; } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum McpToolApprovalDecision { + Accept, + Decline, + Cancel, +} + +struct McpToolApprovalMetadata { + annotations: ToolAnnotations, + connector_name: Option, + tool_title: Option, +} + +const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX: &str = "mcp_tool_call_approval"; +const MCP_TOOL_APPROVAL_ACCEPT: &str = "Accept"; +const MCP_TOOL_APPROVAL_DECLINE: &str = "Decline"; +const MCP_TOOL_APPROVAL_CANCEL: &str = "Cancel"; + +async fn maybe_request_mcp_tool_approval( + sess: &Session, + turn_context: &TurnContext, + call_id: &str, + server: &str, + tool_name: &str, +) -> Option { + if is_full_access_mode(turn_context) { + return None; + } + if server != CODEX_APPS_MCP_SERVER_NAME { + return None; + } + + let metadata = lookup_mcp_tool_metadata(sess, server, tool_name).await?; + if !requires_mcp_tool_approval(&metadata.annotations) { + return None; + } + + let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}"); + let question = build_mcp_tool_approval_question( + question_id.clone(), + tool_name, + metadata.tool_title.as_deref(), + metadata.connector_name.as_deref(), + &metadata.annotations, + ); + let args = RequestUserInputArgs { + questions: vec![question], + }; + let response = sess + .request_user_input(turn_context, call_id.to_string(), args) + .await; + Some(parse_mcp_tool_approval_response(response, &question_id)) +} + +fn is_full_access_mode(turn_context: &TurnContext) -> bool { + matches!(turn_context.approval_policy, AskForApproval::Never) + && matches!( + turn_context.sandbox_policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ) +} + +async fn lookup_mcp_tool_metadata( + sess: &Session, + server: &str, + tool_name: &str, +) -> Option { + let tools = sess + .services + .mcp_connection_manager + .read() + .await + .list_all_tools() + .await; + + tools.into_values().find_map(|tool_info| { + if tool_info.server_name == server && tool_info.tool_name == tool_name { + tool_info + .tool + .annotations + .map(|annotations| McpToolApprovalMetadata { + annotations, + connector_name: tool_info.connector_name, + tool_title: tool_info.tool.title, + }) + } else { + None + } + }) +} + +fn build_mcp_tool_approval_question( + question_id: String, + tool_name: &str, + tool_title: Option<&str>, + connector_name: Option<&str>, + annotations: &ToolAnnotations, +) -> RequestUserInputQuestion { + let destructive = annotations.destructive_hint == Some(true); + let open_world = annotations.open_world_hint == Some(true); + let reason = match (destructive, open_world) { + (true, true) => "may modify data and access external systems", + (true, false) => "may modify or delete data", + (false, true) => "may access external systems", + (false, false) => "may have side effects", + }; + + let tool_label = tool_title.unwrap_or(tool_name); + let app_label = connector_name + .map(|name| format!("The {name} app")) + .unwrap_or_else(|| "This app".to_string()); + let question = format!( + "{app_label} wants to run the tool \"{tool_label}\", which {reason}. Allow this action?" + ); + + RequestUserInputQuestion { + id: question_id, + header: "Approve app tool call?".to_string(), + question, + is_other: false, + is_secret: false, + options: Some(vec![ + RequestUserInputQuestionOption { + label: MCP_TOOL_APPROVAL_ACCEPT.to_string(), + description: "Run the tool and continue.".to_string(), + }, + RequestUserInputQuestionOption { + label: MCP_TOOL_APPROVAL_DECLINE.to_string(), + description: "Decline this tool call and continue.".to_string(), + }, + RequestUserInputQuestionOption { + label: MCP_TOOL_APPROVAL_CANCEL.to_string(), + description: "Cancel this tool call".to_string(), + }, + ]), + } +} + +fn parse_mcp_tool_approval_response( + response: Option, + question_id: &str, +) -> McpToolApprovalDecision { + let Some(response) = response else { + return McpToolApprovalDecision::Cancel; + }; + let answers = response + .answers + .get(question_id) + .map(|answer| answer.answers.as_slice()); + let Some(answers) = answers else { + return McpToolApprovalDecision::Cancel; + }; + if answers + .iter() + .any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT) + { + McpToolApprovalDecision::Accept + } else if answers + .iter() + .any(|answer| answer == MCP_TOOL_APPROVAL_CANCEL) + { + McpToolApprovalDecision::Cancel + } else { + McpToolApprovalDecision::Decline + } +} + +fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool { + annotations.read_only_hint == Some(false) + && (annotations.destructive_hint == Some(true) || annotations.open_world_hint == Some(true)) +} + +async fn notify_mcp_tool_call_skip( + sess: &Session, + turn_context: &TurnContext, + call_id: &str, + invocation: McpInvocation, + message: String, +) -> Result { + let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id: call_id.to_string(), + invocation: invocation.clone(), + }); + notify_mcp_tool_call_event(sess, turn_context, tool_call_begin_event).await; + + let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: call_id.to_string(), + invocation, + duration: Duration::ZERO, + result: Err(message.clone()), + }); + notify_mcp_tool_call_event(sess, turn_context, tool_call_end_event).await; + Err(message) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn annotations( + read_only: Option, + destructive: Option, + open_world: Option, + ) -> ToolAnnotations { + ToolAnnotations { + destructive_hint: destructive, + idempotent_hint: None, + open_world_hint: open_world, + read_only_hint: read_only, + title: None, + } + } + + #[test] + fn approval_required_when_read_only_false_and_destructive() { + let annotations = annotations(Some(false), Some(true), None); + assert_eq!(requires_mcp_tool_approval(&annotations), true); + } + + #[test] + fn approval_required_when_read_only_false_and_open_world() { + let annotations = annotations(Some(false), None, Some(true)); + assert_eq!(requires_mcp_tool_approval(&annotations), true); + } + + #[test] + fn approval_not_required_when_read_only_true() { + let annotations = annotations(Some(true), Some(true), Some(true)); + assert_eq!(requires_mcp_tool_approval(&annotations), false); + } +} diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 9798fb8241..5138b3cd0d 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use std::sync::Arc; use crate::function_tool::FunctionCallError; use crate::mcp_tool_call::handle_mcp_tool_call; @@ -42,7 +43,7 @@ impl ToolHandler for McpHandler { let arguments_str = raw_arguments; let response = handle_mcp_tool_call( - session.as_ref(), + Arc::clone(&session), turn.as_ref(), call_id.clone(), server, From a270a28a063ec3989c7421d1db1d200ac3e5faa5 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 30 Jan 2026 09:00:44 +0100 Subject: [PATCH 46/70] feat: add output to `/ps` (#10154) Screenshot 2026-01-29 at 13 24 57 --- codex-rs/tui/src/chatwidget.rs | 39 ++++++++- codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/history_cell.rs | 82 ++++++++++++++++--- ...put_chunk_leading_whitespace_snapshot.snap | 11 +++ ...ests__ps_output_long_command_snapshot.snap | 1 + ...__tests__ps_output_multiline_snapshot.snap | 3 + 6 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_chunk_leading_whitespace_snapshot.snap diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2833d4863e..9857956ed9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -225,7 +225,9 @@ struct RunningCommand { struct UnifiedExecProcessSummary { key: String, + call_id: String, command_display: String, + recent_chunks: Vec, } struct UnifiedExecWaitState { @@ -1426,6 +1428,8 @@ impl ChatWidget { } fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) { + self.track_unified_exec_output_chunk(&ev.call_id, &ev.chunk); + let Some(cell) = self .active_cell .as_mut() @@ -1545,11 +1549,15 @@ impl ChatWidget { .iter_mut() .find(|process| process.key == key) { + existing.call_id = ev.call_id.clone(); existing.command_display = command_display; + existing.recent_chunks.clear(); } else { self.unified_exec_processes.push(UnifiedExecProcessSummary { key, + call_id: ev.call_id.clone(), command_display, + recent_chunks: Vec::new(), }); } self.sync_unified_exec_footer(); @@ -1574,6 +1582,32 @@ impl ChatWidget { self.bottom_pane.set_unified_exec_processes(processes); } + /// Record recent stdout/stderr lines for the unified exec footer. + fn track_unified_exec_output_chunk(&mut self, call_id: &str, chunk: &[u8]) { + let Some(process) = self + .unified_exec_processes + .iter_mut() + .find(|process| process.call_id == call_id) + else { + return; + }; + + let text = String::from_utf8_lossy(chunk); + for line in text + .lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + { + process.recent_chunks.push(line.to_string()); + } + + const MAX_RECENT_CHUNKS: usize = 3; + if process.recent_chunks.len() > MAX_RECENT_CHUNKS { + let drop_count = process.recent_chunks.len() - MAX_RECENT_CHUNKS; + process.recent_chunks.drain(0..drop_count); + } + } + fn clear_unified_exec_processes(&mut self) { if self.unified_exec_processes.is_empty() { return; @@ -3448,7 +3482,10 @@ impl ChatWidget { let processes = self .unified_exec_processes .iter() - .map(|process| process.command_display.clone()) + .map(|process| history_cell::UnifiedExecProcessDetails { + command_display: process.command_display.clone(), + recent_chunks: process.recent_chunks.clone(), + }) .collect(); self.add_to_history(history_cell::new_unified_exec_processes_output(processes)); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a0e8269b58..144fc199c5 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2025,7 +2025,9 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { chat.on_task_started(); chat.unified_exec_processes.push(UnifiedExecProcessSummary { key: "proc-1".to_string(), + call_id: "call-1".to_string(), command_display: "sleep 5".to_string(), + recent_chunks: Vec::new(), }); chat.on_terminal_interaction(TerminalInteractionEvent { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 687d18fa6a..1f19c929a2 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -558,15 +558,21 @@ pub(crate) fn new_unified_exec_interaction( #[derive(Debug)] struct UnifiedExecProcessesCell { - processes: Vec, + processes: Vec, } impl UnifiedExecProcessesCell { - fn new(processes: Vec) -> Self { + fn new(processes: Vec) -> Self { Self { processes } } } +#[derive(Debug, Clone)] +pub(crate) struct UnifiedExecProcessDetails { + pub(crate) command_display: String, + pub(crate) recent_chunks: Vec, +} + impl HistoryCell for UnifiedExecProcessesCell { fn display_lines(&self, width: u16) -> Vec> { if width == 0 { @@ -589,10 +595,11 @@ impl HistoryCell for UnifiedExecProcessesCell { let truncation_suffix = " [...]"; let truncation_suffix_width = UnicodeWidthStr::width(truncation_suffix); let mut shown = 0usize; - for command in &self.processes { + for process in &self.processes { if shown >= max_processes { break; } + let command = &process.command_display; let (snippet, snippet_truncated) = { let (first_line, has_more_lines) = match command.split_once('\n') { Some((first, _)) => (first, true), @@ -627,6 +634,32 @@ impl HistoryCell for UnifiedExecProcessesCell { let (truncated, _, _) = take_prefix_by_width(&snippet, budget); out.push(vec![prefix.dim(), truncated.cyan()].into()); } + + let chunk_prefix_first = " ↳ "; + let chunk_prefix_next = " "; + for (idx, chunk) in process.recent_chunks.iter().enumerate() { + let chunk_prefix = if idx == 0 { + chunk_prefix_first + } else { + chunk_prefix_next + }; + let chunk_prefix_width = UnicodeWidthStr::width(chunk_prefix); + if wrap_width <= chunk_prefix_width { + out.push(Line::from(chunk_prefix.dim())); + continue; + } + let budget = wrap_width.saturating_sub(chunk_prefix_width); + let (truncated, remainder, _) = take_prefix_by_width(chunk, budget); + if !remainder.is_empty() && budget > truncation_suffix_width { + let available = budget.saturating_sub(truncation_suffix_width); + let (shorter, _, _) = take_prefix_by_width(chunk, available); + out.push( + vec![chunk_prefix.dim(), shorter.dim(), truncation_suffix.dim()].into(), + ); + } else { + out.push(vec![chunk_prefix.dim(), truncated.dim()].into()); + } + } shown += 1; } @@ -650,7 +683,9 @@ impl HistoryCell for UnifiedExecProcessesCell { } } -pub(crate) fn new_unified_exec_processes_output(processes: Vec) -> CompositeHistoryCell { +pub(crate) fn new_unified_exec_processes_output( + processes: Vec, +) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]); let summary = UnifiedExecProcessesCell::new(processes); CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)]) @@ -2022,8 +2057,14 @@ mod tests { #[test] fn ps_output_multiline_snapshot() { let cell = new_unified_exec_processes_output(vec![ - "echo hello\nand then some extra text".to_string(), - "rg \"foo\" src".to_string(), + UnifiedExecProcessDetails { + command_display: "echo hello\nand then some extra text".to_string(), + recent_chunks: vec!["hello".to_string(), "done".to_string()], + }, + UnifiedExecProcessDetails { + command_display: "rg \"foo\" src".to_string(), + recent_chunks: vec!["src/main.rs:12:foo".to_string()], + }, ]); let rendered = render_lines(&cell.display_lines(40)).join("\n"); insta::assert_snapshot!(rendered); @@ -2031,9 +2072,12 @@ mod tests { #[test] fn ps_output_long_command_snapshot() { - let cell = new_unified_exec_processes_output(vec![String::from( - "rg \"foo\" src --glob '**/*.rs' --max-count 1000 --no-ignore --hidden --follow --glob '!target/**'", - )]); + let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails { + command_display: String::from( + "rg \"foo\" src --glob '**/*.rs' --max-count 1000 --no-ignore --hidden --follow --glob '!target/**'", + ), + recent_chunks: vec!["searching...".to_string()], + }]); let rendered = render_lines(&cell.display_lines(36)).join("\n"); insta::assert_snapshot!(rendered); } @@ -2041,12 +2085,30 @@ mod tests { #[test] fn ps_output_many_sessions_snapshot() { let cell = new_unified_exec_processes_output( - (0..20).map(|idx| format!("command {idx}")).collect(), + (0..20) + .map(|idx| UnifiedExecProcessDetails { + command_display: format!("command {idx}"), + recent_chunks: Vec::new(), + }) + .collect(), ); let rendered = render_lines(&cell.display_lines(32)).join("\n"); insta::assert_snapshot!(rendered); } + #[test] + fn ps_output_chunk_leading_whitespace_snapshot() { + let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails { + command_display: "just fix".to_string(), + recent_chunks: vec![ + " indented first".to_string(), + " more indented".to_string(), + ], + }]); + let rendered = render_lines(&cell.display_lines(60)).join("\n"); + insta::assert_snapshot!(rendered); + } + #[tokio::test] async fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config().await; diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_chunk_leading_whitespace_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_chunk_leading_whitespace_snapshot.snap new file mode 100644 index 0000000000..e13dcf0a1e --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_chunk_leading_whitespace_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/ps + +Background terminals + + • just fix + ↳ indented first + more indented diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap index b9302295d3..2bd4bf9fb9 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap @@ -7,3 +7,4 @@ expression: rendered Background terminals • rg "foo" src --glob '**/*. [...] + ↳ searching... diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap index c073349e8f..0e31eddfa0 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap @@ -7,4 +7,7 @@ expression: rendered Background terminals • echo hello [...] + ↳ hello + done • rg "foo" src + ↳ src/main.rs:12:foo From f8056e62d4ee6063af909dc956f67ec509e0c3cd Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 30 Jan 2026 10:02:46 +0100 Subject: [PATCH 47/70] nit: actually run tests (#10217) --- codex-rs/core/tests/suite/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 0744624a92..defc704353 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -73,6 +73,7 @@ mod tool_harness; mod tool_parallelism; mod tools; mod truncation; +mod turn_state; mod undo; mod unified_exec; mod unstable_features_warning; From c0cad80668d8bc94085d8a59da31255d3550c16c Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Fri, 30 Jan 2026 01:14:15 -0800 Subject: [PATCH 48/70] Add community links to startup tooltips (#10177) ## Summary - add startup tooltip for OpenAI community Discord - add startup tooltip for Codex community forum ## Testing - not run (text-only tooltip change) --- codex-rs/tui/tooltips.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 455ae5cd03..84a96f54d0 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -10,6 +10,8 @@ Use /fork to branch the current chat into a new thread. Use /init to create an AGENTS.md with project-specific guidance. Use /mcp to list configured MCP tools. Use the OpenAI docs MCP for API questions; enable it with `codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp`. +Join the OpenAI community Discord: http://discord.gg/openai +Visit the Codex community forum: https://community.openai.com/c/codex/37 You can run any shell command from Codex using `!` (e.g. `!ls`) Type / to open the command popup; Tab autocompletes slash commands. When the composer is empty, press Esc to step back and edit your last message; Enter confirms. From a0ccef9d5c44b7517243163ceb64be8223d5d8d5 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Fri, 30 Jan 2026 01:19:24 -0800 Subject: [PATCH 49/70] Chore: plan mode do not include free form question and always include isOther (#10210) We should never ask a freeform question when planning and we should always include isOther as an escape hatch. --- codex-rs/app-server-protocol/src/protocol/v2.rs | 2 +- codex-rs/app-server/tests/common/responses.rs | 1 - .../core/src/tools/handlers/request_user_input.rs | 14 +++++++++++++- codex-rs/core/src/tools/spec.rs | 13 ++----------- codex-rs/core/tests/suite/request_user_input.rs | 3 +-- codex-rs/docs/protocol_v1.md | 2 +- 6 files changed, 18 insertions(+), 17 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ac3df37bd0..1e28b1b086 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2593,7 +2593,7 @@ pub struct ToolRequestUserInputOption { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -/// EXPERIMENTAL. Represents one request_user_input question and its optional options. +/// EXPERIMENTAL. Represents one request_user_input question and its required options. pub struct ToolRequestUserInputQuestion { pub id: String, pub header: String, diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index 2a5b5d2159..e15319e02f 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -67,7 +67,6 @@ pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result ToolSpec { let options_schema = JsonSchema::Array { description: Some( - "Optional 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; use isOther on the question to request a free form choice. If the question is free form in nature, please do not have any option." + "Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically." .to_string(), ), items: Box::new(JsonSchema::Object { @@ -602,15 +602,6 @@ fn create_request_user_input_tool() -> ToolSpec { description: Some("Single-sentence prompt shown to the user.".to_string()), }, ); - question_props.insert( - "isOther".to_string(), - JsonSchema::Boolean { - description: Some( - "True when this question should include a free-form \"Other\" option. Otherwise false." - .to_string(), - ), - }, - ); question_props.insert("options".to_string(), options_schema); let questions_schema = JsonSchema::Array { @@ -621,7 +612,7 @@ fn create_request_user_input_tool() -> ToolSpec { "id".to_string(), "header".to_string(), "question".to_string(), - "isOther".to_string(), + "options".to_string(), ]), additional_properties: Some(false.into()), }), diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 3872969610..89a5ed3557 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -94,7 +94,6 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> "id": "confirm_path", "header": "Confirm", "question": "Proceed with the plan?", - "isOther": false, "options": [{ "label": "Yes (Recommended)", "description": "Continue the current plan." @@ -153,6 +152,7 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> .await; assert_eq!(request.call_id, call_id); assert_eq!(request.questions.len(), 1); + assert_eq!(request.questions[0].is_other, true); let mut answers = HashMap::new(); answers.insert( @@ -214,7 +214,6 @@ where "id": "confirm_path", "header": "Confirm", "question": "Proceed with the plan?", - "isOther": false, "options": [{ "label": "Yes (Recommended)", "description": "Continue the current plan." diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 8cdc5c9575..74b5546012 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -75,7 +75,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `EventMsg` - `EventMsg::AgentMessage` – Messages from the `Model` - `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command - - `EventMsg::RequestUserInput` – Request user input for a tool call (questions can include options plus `isOther` to add a free-form choice) + - `EventMsg::RequestUserInput` – Request user input for a tool call (questions must include options; the client always adds a free-form choice) - `EventMsg::TurnComplete` – A turn completed successfully - `EventMsg::Error` – A turn stopped with an error - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user From 129787493f47af804ce58716bac5f64e145096c5 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 30 Jan 2026 10:19:41 +0100 Subject: [PATCH 50/70] feat: backfill timing metric (#10218) 1. Add a metric to measure the backfill time 2. Add a unit to the timing histogram --- codex-rs/core/src/rollout/metadata.rs | 12 ++++++++++++ codex-rs/otel/src/metrics/client.rs | 25 ++++++++++++++++++++++++- codex-rs/otel/tests/suite/timing.rs | 11 ++++++++++- codex-rs/state/src/lib.rs | 2 ++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/rollout/metadata.rs b/codex-rs/core/src/rollout/metadata.rs index 5523274bde..2d59d71af3 100644 --- a/codex-rs/core/src/rollout/metadata.rs +++ b/codex-rs/core/src/rollout/metadata.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::SessionSource; use codex_state::BackfillStats; use codex_state::DB_ERROR_METRIC; use codex_state::DB_METRIC_BACKFILL; +use codex_state::DB_METRIC_BACKFILL_DURATION_MS; use codex_state::ExtractionOutcome; use codex_state::ThreadMetadataBuilder; use codex_state::apply_rollout_item; @@ -128,6 +129,7 @@ pub(crate) async fn backfill_sessions( config: &Config, otel: Option<&OtelManager>, ) { + let timer = otel.and_then(|otel| otel.start_timer(DB_METRIC_BACKFILL_DURATION_MS, &[]).ok()); let sessions_root = config.codex_home.join(rollout::SESSIONS_SUBDIR); let archived_root = config.codex_home.join(rollout::ARCHIVED_SESSIONS_SUBDIR); let mut rollout_paths: Vec<(PathBuf, bool)> = Vec::new(); @@ -210,6 +212,16 @@ pub(crate) async fn backfill_sessions( &[("status", "failed")], ); } + if let Some(timer) = timer.as_ref() { + let status = if stats.failed == 0 { + "success" + } else if stats.upserted == 0 { + "failed" + } else { + "partial_failure" + }; + let _ = timer.record(&[("status", status)]); + } } async fn file_modified_time_utc(path: &Path) -> Option> { diff --git a/codex-rs/otel/src/metrics/client.rs b/codex-rs/otel/src/metrics/client.rs index 9b1b01a3ed..62a2df2e8a 100644 --- a/codex-rs/otel/src/metrics/client.rs +++ b/codex-rs/otel/src/metrics/client.rs @@ -34,6 +34,8 @@ use tracing::debug; const ENV_ATTRIBUTE: &str = "env"; const METER_NAME: &str = "codex"; +const DURATION_UNIT: &str = "ms"; +const DURATION_DESCRIPTION: &str = "Duration in milliseconds."; #[derive(Debug)] struct MetricsClientInner { @@ -41,6 +43,7 @@ struct MetricsClientInner { meter: Meter, counters: Mutex>>, histograms: Mutex>>, + duration_histograms: Mutex>>, default_tags: BTreeMap, } @@ -81,6 +84,25 @@ impl MetricsClientInner { Ok(()) } + fn duration_histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) -> Result<()> { + validate_metric_name(name)?; + let attributes = self.attributes(tags)?; + + let mut histograms = self + .duration_histograms + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let histogram = histograms.entry(name.to_string()).or_insert_with(|| { + self.meter + .f64_histogram(name.to_string()) + .with_unit(DURATION_UNIT) + .with_description(DURATION_DESCRIPTION) + .build() + }); + histogram.record(value as f64, &attributes); + Ok(()) + } + fn attributes(&self, tags: &[(&str, &str)]) -> Result> { if tags.is_empty() { return Ok(self @@ -150,6 +172,7 @@ impl MetricsClient { meter, counters: Mutex::new(HashMap::new()), histograms: Mutex::new(HashMap::new()), + duration_histograms: Mutex::new(HashMap::new()), default_tags: config.default_tags, }))) } @@ -171,7 +194,7 @@ impl MetricsClient { duration: Duration, tags: &[(&str, &str)], ) -> Result<()> { - self.histogram( + self.0.duration_histogram( name, duration.as_millis().min(i64::MAX as u128) as i64, tags, diff --git a/codex-rs/otel/tests/suite/timing.rs b/codex-rs/otel/tests/suite/timing.rs index ce4f2f982e..8398e1a0d7 100644 --- a/codex-rs/otel/tests/suite/timing.rs +++ b/codex-rs/otel/tests/suite/timing.rs @@ -18,12 +18,17 @@ fn record_duration_records_histogram() -> Result<()> { )?; metrics.shutdown()?; + let resource_metrics = latest_metrics(&exporter); let (bounds, bucket_counts, sum, count) = - histogram_data(&latest_metrics(&exporter), "codex.request_latency"); + histogram_data(&resource_metrics, "codex.request_latency"); assert!(!bounds.is_empty()); assert_eq!(bucket_counts.iter().sum::(), 1); assert_eq!(sum, 15.0); assert_eq!(count, 1); + let metric = crate::harness::find_metric(&resource_metrics, "codex.request_latency") + .unwrap_or_else(|| panic!("metric codex.request_latency missing")); + assert_eq!(metric.unit(), "ms"); + assert_eq!(metric.description(), "Duration in milliseconds."); Ok(()) } @@ -46,6 +51,10 @@ fn timer_result_records_success() -> Result<()> { assert!(!bounds.is_empty()); assert_eq!(count, 1); assert_eq!(bucket_counts.iter().sum::(), 1); + let metric = crate::harness::find_metric(&resource_metrics, "codex.request_latency") + .unwrap_or_else(|| panic!("metric codex.request_latency missing")); + assert_eq!(metric.unit(), "ms"); + assert_eq!(metric.description(), "Duration in milliseconds."); let attrs = attributes_to_map( match crate::harness::find_metric(&resource_metrics, "codex.request_latency").and_then( |metric| match metric.data() { diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 685552015e..c08c76a1cf 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -34,5 +34,7 @@ pub use runtime::STATE_DB_FILENAME; pub const DB_ERROR_METRIC: &str = "codex.db.error"; /// Metrics on backfill process during first init of the db. Tags: [status] pub const DB_METRIC_BACKFILL: &str = "codex.db.backfill"; +/// Metrics on backfill duration during first init of the db. Tags: [status] +pub const DB_METRIC_BACKFILL_DURATION_MS: &str = "codex.db.backfill.duration_ms"; /// Metrics on errors during comparison between DB and rollout file. Tags: [stage] pub const DB_METRIC_COMPARE_ERROR: &str = "codex.db.compare_error"; From 25ad414680df9b7b53c8bf968c660e5a7e208123 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 30 Jan 2026 11:13:43 +0100 Subject: [PATCH 51/70] chore: unify metric (#10220) --- codex-rs/core/src/state_db.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/core/src/state_db.rs index 7990a017ef..0804273bba 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/core/src/state_db.rs @@ -11,6 +11,7 @@ use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; +use codex_state::DB_METRIC_COMPARE_ERROR; pub use codex_state::LogEntry; use codex_state::STATE_DB_FILENAME; use codex_state::ThreadMetadataBuilder; @@ -282,9 +283,10 @@ pub async fn apply_rollout_items( pub fn record_discrepancy(stage: &str, reason: &str) { // We access the global metric because the call sites might not have access to the broader // OtelManager. + tracing::warn!("state db record_discrepancy: {stage}{reason}"); if let Some(metric) = codex_otel::metrics::global() { let _ = metric.counter( - "codex.db.discrepancy", + DB_METRIC_COMPARE_ERROR, 1, &[("stage", stage), ("reason", reason)], ); From 1ef5455eb6d3f887f6676c451397294594390087 Mon Sep 17 00:00:00 2001 From: pap-openai Date: Fri, 30 Jan 2026 10:40:09 +0000 Subject: [PATCH 52/70] Conversation naming (#8991) Session renaming: - `/rename my_session` - `/rename` without arg and passing an argument in `customViewPrompt` - AppExitInfo shows resume hint using the session name if set instead of uuid, defaults to uuid if not set - Names are stored in `CODEX_HOME/sessions.jsonl` Session resuming: - codex resume lookup for `CODEX_HOME/sessions.jsonl` first entry matching the name and resumes the session --------- Co-authored-by: jif-oai --- .../src/protocol/common.rs | 5 + .../app-server-protocol/src/protocol/v2.rs | 23 ++ codex-rs/app-server/README.md | 1 + .../app-server/src/bespoke_event_handling.rs | 12 + .../app-server/src/codex_message_processor.rs | 35 ++ codex-rs/cli/src/main.rs | 36 +- codex-rs/core/src/codex.rs | 111 +++++- codex-rs/core/src/codex_delegate.rs | 4 + codex-rs/core/src/lib.rs | 2 + codex-rs/core/src/rollout/mod.rs | 2 + codex-rs/core/src/rollout/policy.rs | 1 + codex-rs/core/src/rollout/session_index.rs | 325 ++++++++++++++++++ codex-rs/core/src/util.rs | 76 ++++ .../core/tests/suite/rollout_list_find.rs | 56 +++ codex-rs/exec/Cargo.toml | 1 + codex-rs/exec/src/cli.rs | 4 +- .../src/event_processor_with_human_output.rs | 3 +- .../src/event_processor_with_jsonl_output.rs | 1 + codex-rs/exec/src/lib.rs | 11 +- .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 3 + codex-rs/mcp-server/src/outgoing_message.rs | 3 + codex-rs/protocol/src/protocol.rs | 23 +- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 53 ++- codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 84 ++++- codex-rs/tui/src/chatwidget/tests.rs | 4 + codex-rs/tui/src/lib.rs | 23 +- codex-rs/tui/src/slash_command.rs | 3 + codex-rs/tui/src/status/card.rs | 13 +- codex-rs/tui/src/status/tests.rs | 15 + codex-rs/tui/tooltips.txt | 1 + 33 files changed, 908 insertions(+), 30 deletions(-) create mode 100644 codex-rs/core/src/rollout/session_index.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index b9c58a5883..2e8778bdc5 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -132,6 +132,10 @@ client_request_definitions! { params: v2::ThreadArchiveParams, response: v2::ThreadArchiveResponse, }, + ThreadSetName => "thread/name/set" { + params: v2::ThreadSetNameParams, + response: v2::ThreadSetNameResponse, + }, ThreadUnarchive => "thread/unarchive" { params: v2::ThreadUnarchiveParams, response: v2::ThreadUnarchiveResponse, @@ -598,6 +602,7 @@ server_notification_definitions! { /// NEW NOTIFICATIONS Error => "error" (v2::ErrorNotification), ThreadStarted => "thread/started" (v2::ThreadStartedNotification), + ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification), ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification), TurnStarted => "turn/started" (v2::TurnStartedNotification), TurnCompleted => "turn/completed" (v2::TurnCompletedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1e28b1b086..8bea094487 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1295,6 +1295,14 @@ pub struct ThreadArchiveParams { #[ts(export_to = "v2/")] pub struct ThreadArchiveResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSetNameParams { + pub thread_id: String, + pub name: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1302,6 +1310,11 @@ pub struct ThreadUnarchiveParams { pub thread_id: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSetNameResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2285,6 +2298,16 @@ pub struct ThreadStartedNotification { pub thread: Thread, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadNameUpdatedNotification { + pub thread_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub thread_name: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e9ebcd9661..3798d76027 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -82,6 +82,7 @@ Example (from OpenAI's official VSCode extension): - `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`. - `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success. +- `thread/name/set` — set or update a thread’s user-facing name; returns `{}` on success. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. - `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success. - `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. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 1a9fcaa705..dd2628deb7 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -52,6 +52,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::TerminalInteractionNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -1097,6 +1098,17 @@ pub(crate) async fn apply_bespoke_event_handling( outgoing.send_response(request_id, response).await; } } + EventMsg::ThreadNameUpdated(thread_name_event) => { + if let ApiVersion::V2 = api_version { + let notification = ThreadNameUpdatedNotification { + thread_id: thread_name_event.thread_id.to_string(), + thread_name: thread_name_event.thread_name, + }; + outgoing + .send_server_notification(ServerNotification::ThreadNameUpdated(notification)) + .await; + } + } EventMsg::TurnDiff(turn_diff_event) => { handle_turn_diff( conversation_id, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 660b4b87d0..3817bce3a1 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -110,6 +110,8 @@ use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadSetNameResponse; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadStartParams; @@ -416,6 +418,9 @@ impl CodexMessageProcessor { ClientRequest::ThreadArchive { request_id, params } => { self.thread_archive(request_id, params).await; } + ClientRequest::ThreadSetName { request_id, params } => { + self.thread_set_name(request_id, params).await; + } ClientRequest::ThreadUnarchive { request_id, params } => { self.thread_unarchive(request_id, params).await; } @@ -1780,6 +1785,36 @@ impl CodexMessageProcessor { } } + async fn thread_set_name(&self, request_id: RequestId, params: ThreadSetNameParams) { + let ThreadSetNameParams { thread_id, name } = params; + let Some(name) = codex_core::util::normalize_thread_name(&name) else { + self.send_invalid_request_error( + request_id, + "thread name must not be empty".to_string(), + ) + .await; + return; + }; + + let (_, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + if let Err(err) = thread.submit(Op::SetThreadName { name }).await { + self.send_internal_error(request_id, format!("failed to set thread name: {err}")) + .await; + return; + } + + self.outgoing + .send_response(request_id, ThreadSetNameResponse {}) + .await; + } + async fn thread_unarchive(&mut self, request_id: RequestId, params: ThreadUnarchiveParams) { // TODO(jif) mostly rewrite this using sqlite after phase 1 let thread_id = match ThreadId::from_string(¶ms.thread_id) { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index dd20f04128..7ab8bc937a 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -144,7 +144,7 @@ struct CompletionCommand { #[derive(Debug, Parser)] struct ResumeCommand { - /// Conversation/session id (UUID). When provided, resumes this session. + /// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses. /// If omitted, use --last to pick the most recent recorded session. #[arg(value_name = "SESSION_ID")] session_id: Option, @@ -323,6 +323,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec) -> AppExitInfo { + fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo { let token_usage = TokenUsage { output_tokens: 2, total_tokens: 2, @@ -1036,7 +1038,10 @@ mod tests { }; AppExitInfo { token_usage, - thread_id: conversation.map(ThreadId::from_string).map(Result::unwrap), + thread_id: conversation_id + .map(ThreadId::from_string) + .map(Result::unwrap), + thread_name: thread_name.map(str::to_string), update_action: None, exit_reason: ExitReason::UserRequested, } @@ -1047,6 +1052,7 @@ mod tests { let exit_info = AppExitInfo { token_usage: TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, }; @@ -1056,7 +1062,7 @@ mod tests { #[test] fn format_exit_messages_includes_resume_hint_without_color() { - let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000")); + let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None); let lines = format_exit_messages(exit_info, false); assert_eq!( lines, @@ -1070,12 +1076,28 @@ mod tests { #[test] fn format_exit_messages_applies_color_when_enabled() { - let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000")); + let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None); let lines = format_exit_messages(exit_info, true); assert_eq!(lines.len(), 2); assert!(lines[1].contains("\u{1b}[36m")); } + #[test] + fn format_exit_messages_prefers_thread_name() { + let exit_info = sample_exit_info( + Some("123e4567-e89b-12d3-a456-426614174000"), + Some("my-thread"), + ); + let lines = format_exit_messages(exit_info, false); + assert_eq!( + lines, + vec![ + "Token usage: total=2 input=0 output=2".to_string(), + "To continue this session, run codex resume my-thread".to_string(), + ] + ); + } + #[test] fn resume_model_flag_applies_when_no_root_flags() { let interactive = diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 250ea1ff49..7eefd04ec4 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -26,6 +26,7 @@ use crate::features::maybe_push_unstable_features_warning; use crate::models_manager::manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; +use crate::rollout::session_index; use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::handle_non_tool_response_item; use crate::stream_events_utils::handle_output_item_done; @@ -354,6 +355,8 @@ impl Codex { sandbox_policy: config.sandbox_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, original_config_do_not_use: Arc::clone(&config), session_source, dynamic_tools, @@ -544,6 +547,10 @@ pub(crate) struct SessionConfiguration { /// `ConfigureSession` operation so that the business-logic layer can /// operate deterministically. cwd: PathBuf, + /// Directory containing all Codex state for this session. + codex_home: PathBuf, + /// Optional user-facing name for the thread, updated during the session. + thread_name: Option, // TODO(pakrym): Remove config from here original_config_do_not_use: Arc, @@ -553,6 +560,10 @@ pub(crate) struct SessionConfiguration { } impl SessionConfiguration { + pub(crate) fn codex_home(&self) -> &PathBuf { + &self.codex_home + } + fn thread_config_snapshot(&self) -> ThreadConfigSnapshot { ThreadConfigSnapshot { model: self.collaboration_mode.model().to_string(), @@ -623,6 +634,11 @@ impl Session { per_turn_config } + pub(crate) async fn codex_home(&self) -> PathBuf { + let state = self.state.lock().await; + state.session_configuration.codex_home().clone() + } + #[allow(clippy::too_many_arguments)] fn make_turn_context( auth_manager: Option>, @@ -683,7 +699,7 @@ impl Session { #[allow(clippy::too_many_arguments)] async fn new( - session_configuration: SessionConfiguration, + mut session_configuration: SessionConfiguration, config: Arc, auth_manager: Arc, models_manager: Arc, @@ -863,6 +879,16 @@ impl Session { otel_manager.clone(), ); } + let thread_name = + match session_index::find_thread_name_by_id(&config.codex_home, &conversation_id).await + { + Ok(name) => name, + Err(err) => { + warn!("Failed to read session index for thread name: {err}"); + None + } + }; + session_configuration.thread_name = thread_name.clone(); let state = SessionState::new(session_configuration.clone()); let services = SessionServices { @@ -904,6 +930,7 @@ impl Session { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, forked_from_id, + thread_name: session_configuration.thread_name.clone(), model: session_configuration.collaboration_mode.model().to_string(), model_provider_id: config.model_provider_id.clone(), approval_policy: session_configuration.approval_policy.value(), @@ -1451,8 +1478,7 @@ impl Session { .lock() .await .session_configuration - .original_config_do_not_use - .codex_home + .codex_home() .clone(); if !features.enabled(Feature::ExecPolicy) { @@ -2440,6 +2466,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::ThreadRollback { num_turns } => { handlers::thread_rollback(&sess, sub.id.clone(), num_turns).await; } + Op::SetThreadName { name } => { + handlers::set_thread_name(&sess, sub.id.clone(), name).await; + } Op::RunUserShellCommand { command } => { handlers::run_user_shell_command( &sess, @@ -2483,6 +2512,7 @@ mod handlers { use crate::mcp::collect_mcp_snapshot_from_manager; use crate::mcp::effective_mcp_servers; use crate::review_prompts::resolve_review_request; + use crate::rollout::session_index; use crate::tasks::CompactTask; use crate::tasks::RegularTask; use crate::tasks::UndoTask; @@ -2499,6 +2529,7 @@ mod handlers { use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SkillsListEntry; + use codex_protocol::protocol::ThreadNameUpdatedEvent; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; @@ -2952,6 +2983,72 @@ mod handlers { .await; } + /// Persists the thread name in the session index, updates in-memory state, and emits + /// a `ThreadNameUpdated` event on success. + /// + /// This appends the name to `CODEX_HOME/sessions_index.jsonl` via `session_index::append_thread_name` for the + /// current `thread_id`, then updates `SessionConfiguration::thread_name`. + /// + /// Returns an error event if the name is empty or session persistence is disabled. + pub async fn set_thread_name(sess: &Arc, sub_id: String, name: String) { + let Some(name) = crate::util::normalize_thread_name(&name) else { + let event = Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: "Thread name cannot be empty.".to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + }; + sess.send_event_raw(event).await; + return; + }; + + let persistence_enabled = { + let rollout = sess.services.rollout.lock().await; + rollout.is_some() + }; + if !persistence_enabled { + let event = Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: "Session persistence is disabled; cannot rename thread.".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }; + sess.send_event_raw(event).await; + return; + }; + + let codex_home = sess.codex_home().await; + if let Err(e) = + session_index::append_thread_name(&codex_home, sess.conversation_id, &name).await + { + let event = Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message: format!("Failed to set thread name: {e}"), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }; + sess.send_event_raw(event).await; + return; + } + + { + let mut state = sess.state.lock().await; + state.session_configuration.thread_name = Some(name.clone()); + } + + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent { + thread_id: sess.conversation_id, + thread_name: Some(name), + }), + }) + .await; + } + pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; sess.services @@ -4440,6 +4537,8 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, original_config_do_not_use: Arc::clone(&config), session_source: SessionSource::Exec, dynamic_tools: Vec::new(), @@ -4521,6 +4620,8 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, original_config_do_not_use: Arc::clone(&config), session_source: SessionSource::Exec, dynamic_tools: Vec::new(), @@ -4786,6 +4887,8 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, original_config_do_not_use: Arc::clone(&config), session_source: SessionSource::Exec, dynamic_tools: Vec::new(), @@ -4900,6 +5003,8 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, original_config_do_not_use: Arc::clone(&config), session_source: SessionSource::Exec, dynamic_tools: Vec::new(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 5e1de2ef1f..7a611c05e2 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -208,6 +208,10 @@ async fn forward_events( id: _, msg: EventMsg::SessionConfigured(_), } => {} + Event { + id: _, + msg: EventMsg::ThreadNameUpdated(_), + } => {} Event { id, msg: EventMsg::ExecApprovalRequest(event), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 1b1fb65ecd..63bba76507 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -100,12 +100,14 @@ pub mod turn_diff_tracker; pub use rollout::ARCHIVED_SESSIONS_SUBDIR; pub use rollout::INTERACTIVE_SESSION_SOURCES; pub use rollout::RolloutRecorder; +pub use rollout::RolloutRecorderParams; pub use rollout::SESSIONS_SUBDIR; pub use rollout::SessionMeta; pub use rollout::find_archived_thread_path_by_id_str; #[deprecated(note = "use find_thread_path_by_id_str")] pub use rollout::find_conversation_path_by_id_str; pub use rollout::find_thread_path_by_id_str; +pub use rollout::find_thread_path_by_name_str; pub use rollout::list::Cursor; pub use rollout::list::ThreadItem; pub use rollout::list::ThreadSortKey; diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index cfc2d82d8d..60775d04b0 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -12,6 +12,7 @@ pub mod list; pub(crate) mod metadata; pub(crate) mod policy; pub mod recorder; +pub(crate) mod session_index; pub(crate) mod truncation; pub use codex_protocol::protocol::SessionMeta; @@ -23,6 +24,7 @@ pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str; pub use list::rollout_date_parts; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; +pub use session_index::find_thread_path_by_name_str; #[cfg(test)] pub mod tests; diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 2d263c0539..6de60ae05c 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -58,6 +58,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::RawResponseItem(_) | EventMsg::SessionConfigured(_) + | EventMsg::ThreadNameUpdated(_) | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) | EventMsg::WebSearchBegin(_) diff --git a/codex-rs/core/src/rollout/session_index.rs b/codex-rs/core/src/rollout/session_index.rs new file mode 100644 index 0000000000..98f7e35d84 --- /dev/null +++ b/codex-rs/core/src/rollout/session_index.rs @@ -0,0 +1,325 @@ +use std::fs::File; +use std::io::Read; +use std::io::Seek; +use std::io::SeekFrom; +use std::path::Path; +use std::path::PathBuf; + +use codex_protocol::ThreadId; +use serde::Deserialize; +use serde::Serialize; +use tokio::io::AsyncWriteExt; + +const SESSION_INDEX_FILE: &str = "session_index.jsonl"; +const READ_CHUNK_SIZE: usize = 8192; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SessionIndexEntry { + pub id: ThreadId, + pub thread_name: String, + pub updated_at: String, +} + +/// Append a thread name update to the session index. +/// The index is append-only; the most recent entry wins when resolving names or ids. +pub async fn append_thread_name( + codex_home: &Path, + thread_id: ThreadId, + name: &str, +) -> std::io::Result<()> { + use time::OffsetDateTime; + use time::format_description::well_known::Rfc3339; + + let updated_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "unknown".to_string()); + let entry = SessionIndexEntry { + id: thread_id, + thread_name: name.to_string(), + updated_at, + }; + append_session_index_entry(codex_home, &entry).await +} + +/// Append a raw session index entry to `session_index.jsonl`. +/// The file is append-only; consumers scan from the end to find the newest match. +pub async fn append_session_index_entry( + codex_home: &Path, + entry: &SessionIndexEntry, +) -> std::io::Result<()> { + let path = session_index_path(codex_home); + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .await?; + let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?; + line.push('\n'); + file.write_all(line.as_bytes()).await?; + file.flush().await?; + Ok(()) +} + +/// Find the latest thread name for a thread id, if any. +pub async fn find_thread_name_by_id( + codex_home: &Path, + thread_id: &ThreadId, +) -> std::io::Result> { + let path = session_index_path(codex_home); + if !path.exists() { + return Ok(None); + } + let id = *thread_id; + let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_id(&path, &id)) + .await + .map_err(std::io::Error::other)??; + Ok(entry.map(|entry| entry.thread_name)) +} + +/// Find the most recently updated thread id for a thread name, if any. +pub async fn find_thread_id_by_name( + codex_home: &Path, + name: &str, +) -> std::io::Result> { + if name.trim().is_empty() { + return Ok(None); + } + let path = session_index_path(codex_home); + if !path.exists() { + return Ok(None); + } + let name = name.to_string(); + let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_name(&path, &name)) + .await + .map_err(std::io::Error::other)??; + Ok(entry.map(|entry| entry.id)) +} + +/// Locate a recorded thread rollout file by thread name using newest-first ordering. +/// Returns `Ok(Some(path))` if found, `Ok(None)` if not present. +pub async fn find_thread_path_by_name_str( + codex_home: &Path, + name: &str, +) -> std::io::Result> { + let Some(thread_id) = find_thread_id_by_name(codex_home, name).await? else { + return Ok(None); + }; + super::list::find_thread_path_by_id_str(codex_home, &thread_id.to_string()).await +} + +fn session_index_path(codex_home: &Path) -> PathBuf { + codex_home.join(SESSION_INDEX_FILE) +} + +fn scan_index_from_end_by_id( + path: &Path, + thread_id: &ThreadId, +) -> std::io::Result> { + scan_index_from_end(path, |entry| entry.id == *thread_id) +} + +fn scan_index_from_end_by_name( + path: &Path, + name: &str, +) -> std::io::Result> { + scan_index_from_end(path, |entry| entry.thread_name == name) +} + +fn scan_index_from_end( + path: &Path, + mut predicate: F, +) -> std::io::Result> +where + F: FnMut(&SessionIndexEntry) -> bool, +{ + let mut file = File::open(path)?; + let mut remaining = file.metadata()?.len(); + let mut line_rev: Vec = Vec::new(); + let mut buf = vec![0u8; READ_CHUNK_SIZE]; + + while remaining > 0 { + let read_size = usize::try_from(remaining.min(READ_CHUNK_SIZE as u64)) + .map_err(std::io::Error::other)?; + remaining -= read_size as u64; + file.seek(SeekFrom::Start(remaining))?; + file.read_exact(&mut buf[..read_size])?; + + for &byte in buf[..read_size].iter().rev() { + if byte == b'\n' { + if let Some(entry) = parse_line_from_rev(&mut line_rev, &mut predicate)? { + return Ok(Some(entry)); + } + continue; + } + line_rev.push(byte); + } + } + + if let Some(entry) = parse_line_from_rev(&mut line_rev, &mut predicate)? { + return Ok(Some(entry)); + } + + Ok(None) +} + +fn parse_line_from_rev( + line_rev: &mut Vec, + predicate: &mut F, +) -> std::io::Result> +where + F: FnMut(&SessionIndexEntry) -> bool, +{ + if line_rev.is_empty() { + return Ok(None); + } + line_rev.reverse(); + let line = std::mem::take(line_rev); + let Ok(mut line) = String::from_utf8(line) else { + return Ok(None); + }; + if line.ends_with('\r') { + line.pop(); + } + let trimmed = line.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let Ok(entry) = serde_json::from_str::(trimmed) else { + return Ok(None); + }; + if predicate(&entry) { + return Ok(Some(entry)); + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + fn write_index(path: &Path, lines: &[SessionIndexEntry]) -> std::io::Result<()> { + let mut out = String::new(); + for entry in lines { + out.push_str(&serde_json::to_string(entry).unwrap()); + out.push('\n'); + } + std::fs::write(path, out) + } + + #[test] + fn find_thread_id_by_name_prefers_latest_entry() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id1 = ThreadId::new(); + let id2 = ThreadId::new(); + let lines = vec![ + SessionIndexEntry { + id: id1, + thread_name: "same".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }, + SessionIndexEntry { + id: id2, + thread_name: "same".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + }, + ]; + write_index(&path, &lines)?; + + let found = scan_index_from_end_by_name(&path, "same")?; + assert_eq!(found.map(|entry| entry.id), Some(id2)); + Ok(()) + } + + #[test] + fn find_thread_name_by_id_prefers_latest_entry() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id = ThreadId::new(); + let lines = vec![ + SessionIndexEntry { + id, + thread_name: "first".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }, + SessionIndexEntry { + id, + thread_name: "second".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + }, + ]; + write_index(&path, &lines)?; + + let found = scan_index_from_end_by_id(&path, &id)?; + assert_eq!( + found.map(|entry| entry.thread_name), + Some("second".to_string()) + ); + Ok(()) + } + + #[test] + fn scan_index_returns_none_when_entry_missing() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id = ThreadId::new(); + let lines = vec![SessionIndexEntry { + id, + thread_name: "present".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }]; + write_index(&path, &lines)?; + + let missing_name = scan_index_from_end_by_name(&path, "missing")?; + assert_eq!(missing_name, None); + + let missing_id = scan_index_from_end_by_id(&path, &ThreadId::new())?; + assert_eq!(missing_id, None); + Ok(()) + } + + #[test] + fn scan_index_finds_latest_match_among_mixed_entries() -> std::io::Result<()> { + let temp = TempDir::new()?; + let path = session_index_path(temp.path()); + let id_target = ThreadId::new(); + let id_other = ThreadId::new(); + let expected = SessionIndexEntry { + id: id_target, + thread_name: "target".to_string(), + updated_at: "2024-01-03T00:00:00Z".to_string(), + }; + let expected_other = SessionIndexEntry { + id: id_other, + thread_name: "target".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + }; + // Resolution is based on append order (scan from end), not updated_at. + let lines = vec![ + SessionIndexEntry { + id: id_target, + thread_name: "target".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }, + expected_other.clone(), + expected.clone(), + SessionIndexEntry { + id: ThreadId::new(), + thread_name: "another".to_string(), + updated_at: "2024-01-04T00:00:00Z".to_string(), + }, + ]; + write_index(&path, &lines)?; + + let found_by_name = scan_index_from_end_by_name(&path, "target")?; + assert_eq!(found_by_name, Some(expected.clone())); + + let found_by_id = scan_index_from_end_by_id(&path, &id_target)?; + assert_eq!(found_by_id, Some(expected)); + + let found_other_by_id = scan_index_from_end_by_id(&path, &id_other)?; + assert_eq!(found_other_by_id, Some(expected_other)); + Ok(()) + } +} diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index a100f28443..1a538da558 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -2,10 +2,13 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; +use codex_protocol::ThreadId; use rand::Rng; use tracing::debug; use tracing::error; +use crate::parse_command::shlex_join; + const INITIAL_DELAY_MS: u64 = 200; const BACKOFF_FACTOR: f64 = 2.0; @@ -72,6 +75,32 @@ pub fn resolve_path(base: &Path, path: &PathBuf) -> PathBuf { } } +/// Trim a thread name and return `None` if it is empty after trimming. +pub fn normalize_thread_name(name: &str) -> Option { + let trimmed = name.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub fn resume_command(thread_name: Option<&str>, thread_id: Option) -> Option { + let resume_target = thread_name + .filter(|name| !name.is_empty()) + .map(str::to_string) + .or_else(|| thread_id.map(|thread_id| thread_id.to_string())); + resume_target.map(|target| { + let needs_double_dash = target.starts_with('-'); + let escaped = shlex_join(&[target]); + if needs_double_dash { + format!("codex resume -- {escaped}") + } else { + format!("codex resume {escaped}") + } + }) +} + #[cfg(test)] mod tests { use super::*; @@ -107,4 +136,51 @@ mod tests { feedback_tags!(model = "gpt-5", cached = true, debug_only = OnlyDebug); } + + #[test] + fn normalize_thread_name_trims_and_rejects_empty() { + assert_eq!(normalize_thread_name(" "), None); + assert_eq!( + normalize_thread_name(" my thread "), + Some("my thread".to_string()) + ); + } + + #[test] + fn resume_command_prefers_name_over_id() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let command = resume_command(Some("my-thread"), Some(thread_id)); + assert_eq!(command, Some("codex resume my-thread".to_string())); + } + + #[test] + fn resume_command_with_only_id() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let command = resume_command(None, Some(thread_id)); + assert_eq!( + command, + Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + ); + } + + #[test] + fn resume_command_with_no_name_or_id() { + let command = resume_command(None, None); + assert_eq!(command, None); + } + + #[test] + fn resume_command_quotes_thread_name_when_needed() { + let command = resume_command(Some("-starts-with-dash"), None); + assert_eq!( + command, + Some("codex resume -- -starts-with-dash".to_string()) + ); + + let command = resume_command(Some("two words"), None); + assert_eq!(command, Some("codex resume 'two words'".to_string())); + + let command = resume_command(Some("quote'case"), None); + assert_eq!(command, Some("codex resume \"quote'case\"".to_string())); + } } diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 2af6192dbe..fc3ab80fc3 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -3,8 +3,16 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; +use codex_core::RolloutRecorder; +use codex_core::RolloutRecorderParams; +use codex_core::config::ConfigBuilder; use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_path_by_id_str; +use codex_core::find_thread_path_by_name_str; +use codex_core::protocol::SessionSource; +use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; +use pretty_assertions::assert_eq; use tempfile::TempDir; use uuid::Uuid; @@ -87,6 +95,54 @@ async fn find_ignores_granular_gitignore_rules() { assert_eq!(found, Some(expected)); } +#[tokio::test] +async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()> { + // Ensures the name-based finder locates a rollout produced by the real recorder. + let home = TempDir::new().unwrap(); + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + let thread_id = ThreadId::new(); + let thread_name = "named thread"; + let recorder = RolloutRecorder::new( + &config, + RolloutRecorderParams::new( + thread_id, + None, + SessionSource::Exec, + BaseInstructions::default(), + Vec::new(), + ), + None, + None, + ) + .await?; + recorder.flush().await?; + + let index_path = home.path().join("session_index.jsonl"); + std::fs::write( + &index_path, + format!( + "{}\n", + serde_json::json!({ + "id": thread_id, + "thread_name": thread_name, + "updated_at": "2024-01-01T00:00:00Z" + }) + ), + )?; + + let found = find_thread_path_by_name_str(home.path(), thread_name).await?; + + let path = found.expect("expected rollout path to be found"); + assert!(path.exists()); + let contents = std::fs::read_to_string(&path)?; + assert!(contents.contains(&thread_id.to_string())); + recorder.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn find_archived_locates_rollout_file_by_id() { let home = TempDir::new().unwrap(); diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 9156c22ea3..d4a64772e1 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -47,6 +47,7 @@ ts-rs = { workspace = true, features = [ "serde-json-impl", "no-serde-warnings", ] } +uuid = { workspace = true } [dev-dependencies] diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 4c10bb0d72..2980370de7 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -114,7 +114,7 @@ pub enum Command { struct ResumeArgsRaw { // Note: This is the direct clap shape. We reinterpret the positional when --last is set // so "codex resume --last " treats the positional as a prompt, not a session id. - /// Conversation/session id (UUID). When provided, resumes this session. + /// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses. /// If omitted, use --last to pick the most recent recorded session. #[arg(value_name = "SESSION_ID")] session_id: Option, @@ -144,7 +144,7 @@ struct ResumeArgsRaw { #[derive(Debug)] pub struct ResumeArgs { - /// Conversation/session id (UUID). When provided, resumes this session. + /// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses. /// If omitted, use --last to pick the most recent recorded session. pub session_id: Option, 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 fb7aa54ab4..e11e1fcadb 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -750,7 +750,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { ); } EventMsg::ShutdownComplete => return CodexStatus::Shutdown, - EventMsg::ExecApprovalRequest(_) + EventMsg::ThreadNameUpdated(_) + | EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index d43ce4387c..687df574d6 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -117,6 +117,7 @@ impl EventProcessorWithJsonOutput { pub fn collect_thread_events(&mut self, event: &protocol::Event) -> Vec { match &event.msg { protocol::EventMsg::SessionConfigured(ev) => self.handle_session_configured(ev), + protocol::EventMsg::ThreadNameUpdated(_) => Vec::new(), protocol::EventMsg::AgentMessage(ev) => self.handle_agent_message(ev), protocol::EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), protocol::EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f2c212d895..f7176c13b0 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -59,12 +59,14 @@ use tracing::info; use tracing::warn; use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; +use uuid::Uuid; use crate::cli::Command as ExecCommand; use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use codex_core::default_client::set_default_originator; use codex_core::find_thread_path_by_id_str; +use codex_core::find_thread_path_by_name_str; enum InitialOperation { UserTurn { @@ -619,8 +621,13 @@ async fn resolve_resume_path( } } } else if let Some(id_str) = args.session_id.as_deref() { - let path = find_thread_path_by_id_str(&config.codex_home, id_str).await?; - Ok(path) + if Uuid::parse_str(id_str).is_ok() { + let path = find_thread_path_by_id_str(&config.codex_home, id_str).await?; + Ok(path) + } else { + let path = find_thread_path_by_name_str(&config.codex_home, id_str).await?; + Ok(path) + } } else { Ok(None) } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 105824c60d..dc5f220fd3 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -88,6 +88,7 @@ fn session_configured_produces_thread_started_event() { EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, forked_from_id: None, + thread_name: None, model: "codex-mini-latest".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index dcaf8a89e6..c96bc60706 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -308,6 +308,9 @@ async fn run_codex_tool_session_inner( EventMsg::SessionConfigured(_) => { tracing::error!("unexpected SessionConfigured event"); } + EventMsg::ThreadNameUpdated(_) => { + // Ignore session metadata updates in MCP tool runner. + } EventMsg::AgentMessageDelta(_) => { // TODO: think how we want to support this in the MCP } diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 2069db69d0..954e50d9de 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -258,6 +258,7 @@ mod tests { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: thread_id, forked_from_id: None, + thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -298,6 +299,7 @@ mod tests { let session_configured_event = SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, + thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -362,6 +364,7 @@ mod tests { let session_configured_event = SessionConfiguredEvent { session_id: thread_id, forked_from_id: None, + thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7ba01fa4bd..fbaf4f4457 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -273,6 +273,11 @@ pub enum Op { /// to generate a summary which will be returned as an AgentMessage event. Compact, + /// Set a user-facing thread name in the persisted rollout metadata. + /// This is a local-only operation handled by codex-core; it does not + /// involve the model. + SetThreadName { name: String }, + /// Request Codex to undo a turn (turn are stacked so it is the same effect as CMD + Z). Undo, @@ -735,6 +740,9 @@ pub enum EventMsg { /// Ack the client's configure message. SessionConfigured(SessionConfiguredEvent), + /// Updated session metadata (e.g., thread name changes). + ThreadNameUpdated(ThreadNameUpdatedEvent), + /// Incremental MCP startup progress updates. McpStartupUpdate(McpStartupUpdateEvent), @@ -2186,11 +2194,15 @@ pub struct SkillsListEntry { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { - /// Name left as session_id instead of thread_id for backwards compatibility. pub session_id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] pub forked_from_id: Option, + /// Optional user-facing thread name (may be unset). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub thread_name: Option, + /// Tell the client what model is being queried. pub model: String, @@ -2226,6 +2238,14 @@ pub struct SessionConfiguredEvent { pub rollout_path: Option, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct ThreadNameUpdatedEvent { + pub thread_id: ThreadId, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub thread_name: Option, +} + /// User's decision in response to an ExecApprovalRequest. #[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "snake_case")] @@ -2569,6 +2589,7 @@ mod tests { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, + thread_name: None, model: "codex-mini-latest".to_string(), model_provider_id: "openai".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 6664e7f9c5..7f7fe51c5d 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -94,6 +94,7 @@ tree-sitter-highlight = { workspace = true } unicode-segmentation = { workspace = true } unicode-width = { workspace = true } url = { workspace = true } +uuid = { workspace = true } codex-windows-sandbox = { workspace = true } tokio-util = { workspace = true, features = ["time"] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fe33df8378..107e9f6ed6 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -108,6 +108,7 @@ const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; pub struct AppExitInfo { pub token_usage: TokenUsage, pub thread_id: Option, + pub thread_name: Option, pub update_action: Option, pub exit_reason: ExitReason, } @@ -117,6 +118,7 @@ impl AppExitInfo { Self { token_usage: TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::Fatal(message.into()), } @@ -135,13 +137,17 @@ pub enum ExitReason { Fatal(String), } -fn session_summary(token_usage: TokenUsage, thread_id: Option) -> Option { +fn session_summary( + token_usage: TokenUsage, + thread_id: Option, + thread_name: Option, +) -> Option { if token_usage.is_zero() { return None; } let usage_line = FinalOutput::from(token_usage).to_string(); - let resume_command = thread_id.map(|thread_id| format!("codex resume {thread_id}")); + let resume_command = codex_core::util::resume_command(thread_name.as_deref(), thread_id); Some(SessionSummary { usage_line, resume_command, @@ -496,6 +502,7 @@ async fn handle_model_migration_prompt_if_needed( return Some(AppExitInfo { token_usage: TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, }); @@ -1208,6 +1215,7 @@ impl App { Ok(AppExitInfo { token_usage: app.token_usage(), thread_id: app.chat_widget.thread_id(), + thread_name: app.chat_widget.thread_name(), update_action: app.pending_update_action, exit_reason, }) @@ -1269,8 +1277,11 @@ impl App { match event { AppEvent::NewSession => { let model = self.chat_widget.current_model().to_string(); - let summary = - session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id()); + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); self.shutdown_current_thread().await; if let Err(err) = self.server.remove_and_close_all_threads().await { tracing::warn!(error = %err, "failed to close all threads"); @@ -1343,6 +1354,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), + self.chat_widget.thread_name(), ); match self .server @@ -1401,8 +1413,11 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::ForkCurrentSession => { - let summary = - session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id()); + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); if let Some(path) = self.chat_widget.rollout_path() { match self .server @@ -2241,6 +2256,7 @@ impl App { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: thread_id, forked_from_id: None, + thread_name: None, model: config_snapshot.model, model_provider_id: config_snapshot.model_provider_id, approval_policy: config_snapshot.approval_policy, @@ -2908,6 +2924,7 @@ mod tests { let event = SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2960,6 +2977,7 @@ mod tests { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: base_id, forked_from_id: None, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -3005,6 +3023,7 @@ mod tests { let event = SessionConfiguredEvent { session_id: thread_id, forked_from_id: None, + thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -3036,7 +3055,7 @@ mod tests { #[tokio::test] async fn session_summary_skip_zero_usage() { - assert!(session_summary(TokenUsage::default(), None).is_none()); + assert!(session_summary(TokenUsage::default(), None, None).is_none()); } #[tokio::test] @@ -3049,7 +3068,7 @@ mod tests { }; let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); - let summary = session_summary(usage, Some(conversation)).expect("summary"); + let summary = session_summary(usage, Some(conversation), None).expect("summary"); assert_eq!( summary.usage_line, "Token usage: total=12 input=10 output=2" @@ -3059,4 +3078,22 @@ mod tests { Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) ); } + + #[tokio::test] + async fn session_summary_prefers_name_over_id() { + let usage = TokenUsage { + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + ..Default::default() + }; + let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + + let summary = session_summary(usage, Some(conversation), Some("my-session".to_string())) + .expect("summary"); + assert_eq!( + summary.resume_command, + Some("codex resume my-session".to_string()) + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 90cae4b4a0..7c8b7585ac 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2054,7 +2054,7 @@ impl ChatComposer { self.personality_command_enabled, self.windows_degraded_sandbox_active, ) - && cmd == SlashCommand::Review + && matches!(cmd, SlashCommand::Review | SlashCommand::Rename) { self.textarea.set_text_clearing_elements(""); return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9857956ed9..8223c40ce0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -23,6 +23,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -197,7 +198,6 @@ use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; use crate::streaming::controller::StreamController; -use std::path::Path; use chrono::Local; use codex_common::approval_presets::ApprovalPreset; @@ -517,6 +517,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, thread_id: Option, + thread_name: Option, forked_from: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured @@ -788,6 +789,7 @@ impl ChatWidget { self.set_skills(None); self.bottom_pane.set_connectors_snapshot(None); self.thread_id = Some(event.session_id); + self.thread_name = event.thread_name.clone(); self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); let initial_messages = event.initial_messages.clone(); @@ -828,6 +830,13 @@ impl ChatWidget { } } + fn on_thread_name_updated(&mut self, event: codex_core::protocol::ThreadNameUpdatedEvent) { + if self.thread_id == Some(event.thread_id) { + self.thread_name = event.thread_name; + self.request_redraw(); + } + } + fn set_skills(&mut self, skills: Option>) { self.bottom_pane.set_skills(skills); } @@ -2167,6 +2176,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, @@ -2307,6 +2317,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + thread_name: None, forked_from: None, saw_plan_update_this_turn: false, queued_user_messages: VecDeque::new(), @@ -2436,6 +2447,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, @@ -2706,6 +2718,9 @@ impl ChatWidget { SlashCommand::Review => { self.open_review_popup(); } + SlashCommand::Rename => { + self.show_rename_prompt(); + } SlashCommand::Model => { self.open_model_popup(); } @@ -2907,6 +2922,17 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { + SlashCommand::Rename if !trimmed.is_empty() => { + let Some(name) = codex_core::util::normalize_thread_name(trimmed) else { + self.add_error_message("Thread name cannot be empty.".to_string()); + return; + }; + let cell = Self::rename_confirmation_cell(&name, self.thread_id); + self.add_boxed_history(Box::new(cell)); + self.request_redraw(); + self.app_event_tx + .send(AppEvent::CodexOp(Op::SetThreadName { name })); + } SlashCommand::Collab | SlashCommand::Plan => { let _ = trimmed; self.dispatch_command(cmd); @@ -2925,6 +2951,38 @@ impl ChatWidget { } } + fn show_rename_prompt(&mut self) { + let tx = self.app_event_tx.clone(); + let has_name = self + .thread_name + .as_ref() + .is_some_and(|name| !name.is_empty()); + let title = if has_name { + "Rename thread" + } else { + "Name thread" + }; + let thread_id = self.thread_id; + let view = CustomPromptView::new( + title.to_string(), + "Type a name and press Enter".to_string(), + None, + Box::new(move |name: String| { + let Some(name) = codex_core::util::normalize_thread_name(&name) else { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event("Thread name cannot be empty.".to_string()), + ))); + return; + }; + let cell = Self::rename_confirmation_cell(&name, thread_id); + tx.send(AppEvent::InsertHistoryCell(Box::new(cell))); + tx.send(AppEvent::CodexOp(Op::SetThreadName { name })); + }), + ); + + self.bottom_pane.show_view(Box::new(view)); + } + pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } @@ -3125,7 +3183,10 @@ impl ChatWidget { /// distinguish replayed events from live ones. fn replay_initial_messages(&mut self, events: Vec) { for msg in events { - if matches!(msg, EventMsg::SessionConfigured(_)) { + if matches!( + msg, + EventMsg::SessionConfigured(_) | EventMsg::ThreadNameUpdated(_) + ) { continue; } // `id: None` indicates a synthetic/fake id coming from replay. @@ -3169,6 +3230,7 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), + EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e), EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message), EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) @@ -3468,6 +3530,7 @@ impl ChatWidget { token_info, total_usage, &self.thread_id, + self.thread_name.clone(), self.forked_from, self.rate_limit_snapshot.as_ref(), self.plan_type, @@ -5275,6 +5338,20 @@ impl ChatWidget { self.request_redraw(); } + fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { + let resume_cmd = codex_core::util::resume_command(Some(name), thread_id) + .unwrap_or_else(|| format!("codex resume {name}")); + let name = name.to_string(); + let line = vec![ + "• ".into(), + "Thread renamed to ".into(), + name.cyan(), + ", to resume this thread run ".into(), + resume_cmd.cyan(), + ]; + PlainHistoryCell::new(vec![line.into()]) + } + pub(crate) fn add_mcp_output(&mut self) { if self.config.mcp_servers.is_empty() { self.add_to_history(history_cell::empty_mcp_output()); @@ -5796,6 +5873,9 @@ impl ChatWidget { self.thread_id } + pub(crate) fn thread_name(&self) -> Option { + self.thread_name.clone() + } pub(crate) fn rollout_path(&self) -> Option { self.current_rollout_path.clone() } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 144fc199c5..331afdf90a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -134,6 +134,7 @@ async fn resumed_initial_messages_render_history() { let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, + thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -200,6 +201,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, + thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -252,6 +254,7 @@ async fn submission_preserves_text_elements_and_local_images() { let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, + thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -823,6 +826,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + thread_name: None, forked_from: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 010f8c83e5..c9f307dbe4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -26,6 +26,7 @@ use codex_core::config::resolve_oss_provider; use codex_core::config_loader::ConfigLoadError; use codex_core::config_loader::format_config_error_with_source; use codex_core::find_thread_path_by_id_str; +use codex_core::find_thread_path_by_name_str; use codex_core::path_utils; use codex_core::protocol::AskForApproval; use codex_core::read_session_meta_line; @@ -47,6 +48,7 @@ use tracing::error; use tracing_appender::non_blocking; use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; +use uuid::Uuid; mod additional_dirs; mod app; @@ -411,6 +413,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + thread_name: None, update_action: Some(action), exit_reason: ExitReason::UserRequested, }); @@ -451,6 +454,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, }); @@ -484,6 +488,7 @@ async fn run_ratatui_app( Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::Fatal(format!( "No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions." @@ -494,7 +499,13 @@ async fn run_ratatui_app( let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some(); let session_selection = if use_fork { if let Some(id_str) = cli.fork_session_id.as_deref() { - match find_thread_path_by_id_str(&config.codex_home, id_str).await? { + let is_uuid = Uuid::parse_str(id_str).is_ok(); + let path = if is_uuid { + find_thread_path_by_id_str(&config.codex_home, id_str).await? + } else { + find_thread_path_by_name_str(&config.codex_home, id_str).await? + }; + match path { Some(path) => resume_picker::SessionSelection::Fork(path), None => return missing_session_exit(id_str, "fork"), } @@ -533,6 +544,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, }); @@ -543,7 +555,13 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh } } else if let Some(id_str) = cli.resume_session_id.as_deref() { - match find_thread_path_by_id_str(&config.codex_home, id_str).await? { + let is_uuid = Uuid::parse_str(id_str).is_ok(); + let path = if is_uuid { + find_thread_path_by_id_str(&config.codex_home, id_str).await? + } else { + find_thread_path_by_name_str(&config.codex_home, id_str).await? + }; + match path { Some(path) => resume_picker::SessionSelection::Resume(path), None => return missing_session_exit(id_str, "resume"), } @@ -584,6 +602,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), thread_id: None, + thread_name: None, update_action: None, exit_reason: ExitReason::UserRequested, }); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 9488577c1b..4b68645aef 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -20,6 +20,7 @@ pub enum SlashCommand { Experimental, Skills, Review, + Rename, New, Resume, Fork, @@ -53,6 +54,7 @@ impl SlashCommand { SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", + SlashCommand::Rename => "rename the current thread", SlashCommand::Resume => "resume a saved chat", SlashCommand::Fork => "fork the current chat", // SlashCommand::Undo => "ask Codex to undo a turn", @@ -103,6 +105,7 @@ impl SlashCommand { | SlashCommand::Review | SlashCommand::Logout => false, SlashCommand::Diff + | SlashCommand::Rename | SlashCommand::Mention | SlashCommand::Skills | SlashCommand::Status diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 5e2fe57b8a..23005274db 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -68,6 +68,7 @@ struct StatusHistoryCell { collaboration_mode: Option, model_provider: Option, account: Option, + thread_name: Option, session_id: Option, forked_from: Option, token_usage: StatusTokenUsageData, @@ -81,6 +82,7 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + thread_name: Option, forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, @@ -96,6 +98,7 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, + thread_name, forked_from, rate_limits, plan_type, @@ -116,6 +119,7 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + thread_name: Option, forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, @@ -197,6 +201,7 @@ impl StatusHistoryCell { collaboration_mode: collaboration_mode.map(ToString::to_string), model_provider, account, + thread_name, session_id, forked_from, token_usage, @@ -377,6 +382,7 @@ impl HistoryCell for StatusHistoryCell { .map(str::to_string) .collect(); let mut seen: BTreeSet = labels.iter().cloned().collect(); + let thread_name = self.thread_name.as_deref().filter(|name| !name.is_empty()); if self.model_provider.is_some() { push_label(&mut labels, &mut seen, "Model provider"); @@ -384,6 +390,9 @@ impl HistoryCell for StatusHistoryCell { if account_value.is_some() { push_label(&mut labels, &mut seen, "Account"); } + if thread_name.is_some() { + push_label(&mut labels, &mut seen, "Thread name"); + } if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } @@ -442,10 +451,12 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + if let Some(thread_name) = thread_name { + lines.push(formatter.line("Thread name", vec![Span::from(thread_name.to_string())])); + } if let Some(collab_mode) = self.collaboration_mode.as_ref() { lines.push(formatter.line("Collaboration mode", vec![Span::from(collab_mode.clone())])); } - if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 92ed340817..c8844904ab 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -148,6 +148,7 @@ async fn status_snapshot_includes_reasoning_details() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -200,6 +201,7 @@ async fn status_snapshot_includes_forked_from() { Some(&token_info), &usage, &Some(session_id), + None, Some(forked_from), None, None, @@ -260,6 +262,7 @@ async fn status_snapshot_includes_monthly_limit() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -307,6 +310,7 @@ async fn status_snapshot_shows_unlimited_credits() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -353,6 +357,7 @@ async fn status_snapshot_shows_positive_credits() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -399,6 +404,7 @@ async fn status_snapshot_hides_zero_credits() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -443,6 +449,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -489,6 +496,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { None, None, None, + None, now, &model_slug, None, @@ -546,6 +554,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -596,6 +605,7 @@ async fn status_snapshot_shows_missing_limits_message() { None, None, None, + None, now, &model_slug, None, @@ -660,6 +670,7 @@ async fn status_snapshot_includes_credits_and_limits() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -714,6 +725,7 @@ async fn status_snapshot_shows_empty_limits_message() { &usage, &None, None, + None, Some(&rate_display), None, captured_at, @@ -777,6 +789,7 @@ async fn status_snapshot_shows_stale_limits_message() { &usage, &None, None, + None, Some(&rate_display), None, now, @@ -844,6 +857,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { &usage, &None, None, + None, Some(&rate_display), None, now, @@ -903,6 +917,7 @@ async fn status_context_window_uses_last_usage() { None, None, None, + None, now, &model_slug, None, diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 84a96f54d0..a8521f359f 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -9,6 +9,7 @@ Use /status to see the current model, approvals, and token usage. Use /fork to branch the current chat into a new thread. Use /init to create an AGENTS.md with project-specific guidance. Use /mcp to list configured MCP tools. +Use /rename to rename your threads for easier thread resuming. Use the OpenAI docs MCP for API questions; enable it with `codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp`. Join the OpenAI community Discord: http://discord.gg/openai Visit the Codex community forum: https://community.openai.com/c/codex/37 From e85d019daaa4c42fc9404678f5dec52e424c9f6a Mon Sep 17 00:00:00 2001 From: gt-oai Date: Fri, 30 Jan 2026 12:03:29 +0000 Subject: [PATCH 53/70] Fetch Requirements from cloud (#10167) Load requirements from Codex Backend. It only does this for enterprise customers signed in with ChatGPT. Todo in follow-up PRs: * Add to app-server and exec too * Switch from fail-open to fail-closed on failure --- codex-rs/Cargo.lock | 20 ++ codex-rs/Cargo.toml | 2 + codex-rs/cloud-requirements/BUILD.bazel | 6 + codex-rs/cloud-requirements/Cargo.toml | 26 ++ codex-rs/cloud-requirements/src/lib.rs | 338 ++++++++++++++++++ codex-rs/core/src/config/mod.rs | 35 +- codex-rs/core/src/config/service.rs | 1 + codex-rs/core/src/config_loader/README.md | 3 +- .../src/config_loader/cloud_requirements.rs | 56 +++ .../src/config_loader/config_requirements.rs | 31 ++ codex-rs/core/src/config_loader/mod.rs | 11 + codex-rs/core/src/config_loader/tests.rs | 103 ++++++ codex-rs/core/src/skills/manager.rs | 1 + codex-rs/exec-server/src/posix.rs | 1 + codex-rs/network-proxy/src/state.rs | 7 +- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/lib.rs | 48 ++- 17 files changed, 673 insertions(+), 17 deletions(-) create mode 100644 codex-rs/cloud-requirements/BUILD.bazel create mode 100644 codex-rs/cloud-requirements/Cargo.toml create mode 100644 codex-rs/cloud-requirements/src/lib.rs create mode 100644 codex-rs/core/src/config_loader/cloud_requirements.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 35d3129106..44f35f78ae 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1292,6 +1292,25 @@ dependencies = [ "zstd", ] +[[package]] +name = "codex-cloud-requirements" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64", + "codex-app-server-protocol", + "codex-backend-client", + "codex-core", + "codex-otel", + "codex-protocol", + "pretty_assertions", + "serde_json", + "tempfile", + "tokio", + "toml 0.9.5", + "tracing", +] + [[package]] name = "codex-cloud-tasks" version = "0.0.0" @@ -1895,6 +1914,7 @@ dependencies = [ "codex-backend-client", "codex-chatgpt", "codex-cli", + "codex-cloud-requirements", "codex-common", "codex-core", "codex-feedback", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5196213991..327e6beeb0 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -11,6 +11,7 @@ members = [ "arg0", "feedback", "codex-backend-openapi-models", + "cloud-requirements", "cloud-tasks", "cloud-tasks-client", "cli", @@ -71,6 +72,7 @@ codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } codex-async-utils = { path = "async-utils" } codex-backend-client = { path = "backend-client" } +codex-cloud-requirements = { path = "cloud-requirements" } codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli"} codex-client = { path = "codex-client" } diff --git a/codex-rs/cloud-requirements/BUILD.bazel b/codex-rs/cloud-requirements/BUILD.bazel new file mode 100644 index 0000000000..88243aff90 --- /dev/null +++ b/codex-rs/cloud-requirements/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "cloud-requirements", + crate_name = "codex_cloud_requirements", +) diff --git a/codex-rs/cloud-requirements/Cargo.toml b/codex-rs/cloud-requirements/Cargo.toml new file mode 100644 index 0000000000..2bfde660bf --- /dev/null +++ b/codex-rs/cloud-requirements/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "codex-cloud-requirements" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-backend-client = { workspace = true } +codex-core = { workspace = true } +codex-otel = { workspace = true } +codex-protocol = { workspace = true } +tokio = { workspace = true, features = ["sync", "time"] } +toml = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +base64 = { workspace = true } +pretty_assertions = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "test-util", "time"] } diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs new file mode 100644 index 0000000000..ccfa600319 --- /dev/null +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -0,0 +1,338 @@ +//! Cloud-hosted config requirements for Codex. +//! +//! This crate fetches `requirements.toml` data from the backend as an alternative to loading it +//! from the local filesystem. It only applies to Enterprise ChatGPT customers. +//! +//! Today, fetching is best-effort: on error or timeout, Codex continues without cloud requirements. +//! We expect to tighten this so that Enterprise ChatGPT customers must successfully fetch these +//! requirements before Codex will run. + +use async_trait::async_trait; +use codex_app_server_protocol::AuthMode; +use codex_backend_client::Client as BackendClient; +use codex_core::AuthManager; +use codex_core::auth::CodexAuth; +use codex_core::config_loader::CloudRequirementsLoader; +use codex_core::config_loader::ConfigRequirementsToml; +use codex_protocol::account::PlanType; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use tokio::time::timeout; + +/// This blocks codecs startup, so must be short. +const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(5); + +#[async_trait] +trait RequirementsFetcher: Send + Sync { + /// Returns requirements as a TOML string. + /// + /// TODO(gt): For now, returns an Option. But when we want to make this fail-closed, return a + /// Result. + async fn fetch_requirements(&self, auth: &CodexAuth) -> Option; +} + +struct BackendRequirementsFetcher { + base_url: String, +} + +impl BackendRequirementsFetcher { + fn new(base_url: String) -> Self { + Self { base_url } + } +} + +#[async_trait] +impl RequirementsFetcher for BackendRequirementsFetcher { + async fn fetch_requirements(&self, auth: &CodexAuth) -> Option { + let client = BackendClient::from_auth(self.base_url.clone(), auth) + .inspect_err(|err| { + tracing::warn!( + error = %err, + "Failed to construct backend client for cloud requirements" + ); + }) + .ok()?; + + let response = client + .get_config_requirements_file() + .await + .inspect_err(|err| tracing::warn!(error = %err, "Failed to fetch cloud requirements")) + .ok()?; + + let Some(contents) = response.contents else { + tracing::warn!("Cloud requirements response missing contents"); + return None; + }; + + Some(contents) + } +} + +struct CloudRequirementsService { + auth_manager: Arc, + fetcher: Arc, + timeout: Duration, +} + +impl CloudRequirementsService { + fn new( + auth_manager: Arc, + fetcher: Arc, + timeout: Duration, + ) -> Self { + Self { + auth_manager, + fetcher, + timeout, + } + } + + async fn fetch_with_timeout(&self) -> Option { + let _timer = + codex_otel::start_global_timer("codex.cloud_requirements.fetch.duration_ms", &[]); + let started_at = Instant::now(); + let result = timeout(self.timeout, self.fetch()) + .await + .inspect_err(|_| { + tracing::warn!("Timed out waiting for cloud requirements; continuing without them"); + }) + .ok()?; + + match result.as_ref() { + Some(requirements) => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + requirements = ?requirements, + "Cloud requirements load completed" + ); + } + None => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + "Cloud requirements load completed (none)" + ); + } + } + + result + } + + async fn fetch(&self) -> Option { + let auth = self.auth_manager.auth().await?; + if !(auth.mode == AuthMode::ChatGPT + && auth.account_plan_type() == Some(PlanType::Enterprise)) + { + return None; + } + + let contents = self.fetcher.fetch_requirements(&auth).await?; + parse_cloud_requirements(&contents) + .inspect_err(|err| tracing::warn!(error = %err, "Failed to parse cloud requirements")) + .ok() + .flatten() + } +} + +pub fn cloud_requirements_loader( + auth_manager: Arc, + chatgpt_base_url: String, +) -> CloudRequirementsLoader { + let service = CloudRequirementsService::new( + auth_manager, + Arc::new(BackendRequirementsFetcher::new(chatgpt_base_url)), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let task = tokio::spawn(async move { service.fetch_with_timeout().await }); + CloudRequirementsLoader::new(async move { + task.await + .inspect_err(|err| tracing::warn!(error = %err, "Cloud requirements task failed")) + .ok() + .flatten() + }) +} + +fn parse_cloud_requirements( + contents: &str, +) -> Result, toml::de::Error> { + if contents.trim().is_empty() { + return Ok(None); + } + + let requirements: ConfigRequirementsToml = toml::from_str(contents)?; + if requirements.is_empty() { + Ok(None) + } else { + Ok(Some(requirements)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use codex_core::auth::AuthCredentialsStoreMode; + use codex_protocol::protocol::AskForApproval; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::future::pending; + use std::path::Path; + use tempfile::tempdir; + + fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> { + std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?; + Ok(()) + } + + fn auth_manager_with_api_key() -> Arc { + let tmp = tempdir().expect("tempdir"); + let auth_json = json!({ + "OPENAI_API_KEY": "sk-test-key", + "tokens": null, + "last_refresh": null, + }); + write_auth_json(tmp.path(), auth_json).expect("write auth"); + Arc::new(AuthManager::new( + tmp.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )) + } + + fn auth_manager_with_plan(plan_type: &str) -> Arc { + let tmp = tempdir().expect("tempdir"); + let header = json!({ "alg": "none", "typ": "JWT" }); + let auth_payload = json!({ + "chatgpt_plan_type": plan_type, + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + }); + let payload = json!({ + "email": "user@example.com", + "https://api.openai.com/auth": auth_payload, + }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header")); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload")); + let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let auth_json = json!({ + "OPENAI_API_KEY": null, + "tokens": { + "id_token": fake_jwt, + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + }, + "last_refresh": null, + }); + write_auth_json(tmp.path(), auth_json).expect("write auth"); + Arc::new(AuthManager::new( + tmp.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )) + } + + fn parse_for_fetch(contents: Option<&str>) -> Option { + contents.and_then(|contents| parse_cloud_requirements(contents).ok().flatten()) + } + + struct StaticFetcher { + contents: Option, + } + + #[async_trait::async_trait] + impl RequirementsFetcher for StaticFetcher { + async fn fetch_requirements(&self, _auth: &CodexAuth) -> Option { + self.contents.clone() + } + } + + struct PendingFetcher; + + #[async_trait::async_trait] + impl RequirementsFetcher for PendingFetcher { + async fn fetch_requirements(&self, _auth: &CodexAuth) -> Option { + pending::<()>().await; + None + } + } + + #[tokio::test] + async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { + let auth_manager = auth_manager_with_api_key(); + let service = CloudRequirementsService::new( + auth_manager, + Arc::new(StaticFetcher { contents: None }), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let result = service.fetch().await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn fetch_cloud_requirements_skips_non_enterprise_plan() { + let auth_manager = auth_manager_with_plan("pro"); + let service = CloudRequirementsService::new( + auth_manager, + Arc::new(StaticFetcher { contents: None }), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let result = service.fetch().await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn fetch_cloud_requirements_handles_missing_contents() { + let result = parse_for_fetch(None); + assert!(result.is_none()); + } + + #[tokio::test] + async fn fetch_cloud_requirements_handles_empty_contents() { + let result = parse_for_fetch(Some(" ")); + assert!(result.is_none()); + } + + #[tokio::test] + async fn fetch_cloud_requirements_handles_invalid_toml() { + let result = parse_for_fetch(Some("not = [")); + assert!(result.is_none()); + } + + #[tokio::test] + async fn fetch_cloud_requirements_ignores_empty_requirements() { + let result = parse_for_fetch(Some("# comment")); + assert!(result.is_none()); + } + + #[tokio::test] + async fn fetch_cloud_requirements_parses_valid_toml() { + let result = parse_for_fetch(Some("allowed_approval_policies = [\"never\"]")); + + assert_eq!( + result, + Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_sandbox_modes: None, + mcp_servers: None, + }) + ); + } + + #[tokio::test(start_paused = true)] + async fn fetch_cloud_requirements_times_out() { + let auth_manager = auth_manager_with_plan("enterprise"); + let service = CloudRequirementsService::new( + auth_manager, + Arc::new(PendingFetcher), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let handle = tokio::spawn(async move { service.fetch_with_timeout().await }); + tokio::time::advance(CLOUD_REQUIREMENTS_TIMEOUT + Duration::from_millis(1)).await; + + let result = handle.await.expect("cloud requirements task"); + assert!(result.is_none()); + } +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index bc7dba6d71..fbd8999918 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -18,6 +18,7 @@ use crate::config::types::ShellEnvironmentPolicyToml; use crate::config::types::SkillsConfig; use crate::config::types::Tui; use crate::config::types::UriBasedFileOpener; +use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::LoaderOverrides; @@ -366,6 +367,7 @@ pub struct ConfigBuilder { cli_overrides: Option>, harness_overrides: Option, loader_overrides: Option, + cloud_requirements: Option, fallback_cwd: Option, } @@ -390,6 +392,11 @@ impl ConfigBuilder { self } + pub fn cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { + self.cloud_requirements = Some(cloud_requirements); + self + } + pub fn fallback_cwd(mut self, fallback_cwd: Option) -> Self { self.fallback_cwd = fallback_cwd; self @@ -401,6 +408,7 @@ impl ConfigBuilder { cli_overrides, harness_overrides, loader_overrides, + cloud_requirements, fallback_cwd, } = self; let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?; @@ -413,9 +421,14 @@ impl ConfigBuilder { None => AbsolutePathBuf::current_dir()?, }; harness_overrides.cwd = Some(cwd.to_path_buf()); - let config_layer_stack = - load_config_layers_state(&codex_home, Some(cwd), &cli_overrides, loader_overrides) - .await?; + let config_layer_stack = load_config_layers_state( + &codex_home, + Some(cwd), + &cli_overrides, + loader_overrides, + cloud_requirements, + ) + .await?; let merged_toml = config_layer_stack.effective_config(); // Note that each layer in ConfigLayerStack should have resolved @@ -511,6 +524,7 @@ pub async fn load_config_as_toml_with_cli_overrides( Some(cwd.clone()), &cli_overrides, LoaderOverrides::default(), + None, ) .await?; @@ -609,9 +623,14 @@ pub async fn load_global_mcp_servers( // There is no cwd/project context for this query, so this will not include // MCP servers defined in in-repo .codex/ folders. let cwd: Option = None; - let config_layer_stack = - load_config_layers_state(codex_home, cwd, &cli_overrides, LoaderOverrides::default()) - .await?; + let config_layer_stack = load_config_layers_state( + codex_home, + cwd, + &cli_overrides, + LoaderOverrides::default(), + None, + ) + .await?; let merged_toml = config_layer_stack.effective_config(); let Some(servers_value) = merged_toml.get("mcp_servers") else { return Ok(BTreeMap::new()); @@ -2612,7 +2631,8 @@ profile = "project" let cwd = AbsolutePathBuf::try_from(codex_home.path())?; let config_layer_stack = - load_config_layers_state(codex_home.path(), Some(cwd), &Vec::new(), overrides).await?; + load_config_layers_state(codex_home.path(), Some(cwd), &Vec::new(), overrides, None) + .await?; let cfg = deserialize_config_toml_with_base( config_layer_stack.effective_config(), codex_home.path(), @@ -2739,6 +2759,7 @@ profile = "project" Some(cwd), &[("model".to_string(), TomlValue::String("cli".to_string()))], overrides, + None, ) .await?; diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 961c40e02b..82583ee8a8 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -376,6 +376,7 @@ impl ConfigService { cwd, &self.cli_overrides, self.loader_overrides.clone(), + None, ) .await } diff --git a/codex-rs/core/src/config_loader/README.md b/codex-rs/core/src/config_loader/README.md index 67170644f5..04b72e4ca1 100644 --- a/codex-rs/core/src/config_loader/README.md +++ b/codex-rs/core/src/config_loader/README.md @@ -10,7 +10,7 @@ This module is the canonical place to **load and describe Codex configuration la Exported from `codex_core::config_loader`: -- `load_config_layers_state(codex_home, cwd_opt, cli_overrides, overrides) -> ConfigLayerStack` +- `load_config_layers_state(codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` @@ -49,6 +49,7 @@ let layers = load_config_layers_state( Some(cwd), &cli_overrides, LoaderOverrides::default(), + None, ).await?; let effective = layers.effective_config(); diff --git a/codex-rs/core/src/config_loader/cloud_requirements.rs b/codex-rs/core/src/config_loader/cloud_requirements.rs new file mode 100644 index 0000000000..7c5e3a2947 --- /dev/null +++ b/codex-rs/core/src/config_loader/cloud_requirements.rs @@ -0,0 +1,56 @@ +use crate::config_loader::ConfigRequirementsToml; +use futures::future::BoxFuture; +use futures::future::FutureExt; +use futures::future::Shared; +use std::fmt; +use std::future::Future; + +#[derive(Clone)] +pub struct CloudRequirementsLoader { + // TODO(gt): This should return a Result once we can fail-closed. + fut: Shared>>, +} + +impl CloudRequirementsLoader { + pub fn new(fut: F) -> Self + where + F: Future> + Send + 'static, + { + Self { + fut: fut.boxed().shared(), + } + } + + pub async fn get(&self) -> Option { + self.fut.clone().await + } +} + +impl fmt::Debug for CloudRequirementsLoader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CloudRequirementsLoader").finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + #[tokio::test] + async fn shared_future_runs_once() { + let counter = Arc::new(AtomicUsize::new(0)); + let counter_clone = Arc::clone(&counter); + let loader = CloudRequirementsLoader::new(async move { + counter_clone.fetch_add(1, Ordering::SeqCst); + Some(ConfigRequirementsToml::default()) + }); + + let (first, second) = tokio::join!(loader.get(), loader.get()); + assert_eq!(first, second); + assert_eq!(counter.load(Ordering::SeqCst), 1); + } +} diff --git a/codex-rs/core/src/config_loader/config_requirements.rs b/codex-rs/core/src/config_loader/config_requirements.rs index a83398c711..2c220049cf 100644 --- a/codex-rs/core/src/config_loader/config_requirements.rs +++ b/codex-rs/core/src/config_loader/config_requirements.rs @@ -13,6 +13,7 @@ use crate::config::ConstraintError; pub enum RequirementSource { Unknown, MdmManagedPreferences { domain: String, key: String }, + CloudRequirements, SystemRequirementsToml { file: AbsolutePathBuf }, LegacyManagedConfigTomlFromFile { file: AbsolutePathBuf }, LegacyManagedConfigTomlFromMdm, @@ -25,6 +26,9 @@ impl fmt::Display for RequirementSource { RequirementSource::MdmManagedPreferences { domain, key } => { write!(f, "MDM {domain}:{key}") } + RequirementSource::CloudRequirements => { + write!(f, "cloud requirements") + } RequirementSource::SystemRequirementsToml { file } => { write!(f, "{}", file.as_path().display()) } @@ -448,6 +452,33 @@ mod tests { Ok(()) } + #[test] + fn constraint_error_includes_cloud_requirements_source() -> Result<()> { + let source: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["on-request"] + "#, + )?; + + let source_location = RequirementSource::CloudRequirements; + + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields(source_location.clone(), source); + let requirements = ConfigRequirements::try_from(target)?; + + assert_eq!( + requirements.approval_policy.can_set(&AskForApproval::Never), + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: "Never".into(), + allowed: "[OnRequest]".into(), + requirement_source: source_location, + }) + ); + + Ok(()) + } + #[test] fn deserialize_allowed_approval_policies() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 5db10f2808..0dd704cab5 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -1,3 +1,4 @@ +mod cloud_requirements; mod config_requirements; mod diagnostics; mod fingerprint; @@ -30,6 +31,7 @@ use std::io; use std::path::Path; use toml::Value as TomlValue; +pub use cloud_requirements::CloudRequirementsLoader; pub use config_requirements::ConfigRequirements; pub use config_requirements::ConfigRequirementsToml; pub use config_requirements::McpServerIdentity; @@ -69,6 +71,7 @@ const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; /// earlier layer cannot be overridden by a later layer: /// /// - admin: managed preferences (*) +/// - cloud: managed cloud requirements /// - system `/etc/codex/requirements.toml` /// /// For backwards compatibility, we also load from @@ -98,6 +101,7 @@ pub async fn load_config_layers_state( cwd: Option, cli_overrides: &[(String, TomlValue)], overrides: LoaderOverrides, + cloud_requirements: Option, // TODO(gt): Once exec and app-server are wired up, we can remove the option. ) -> io::Result { let mut config_requirements_toml = ConfigRequirementsWithSources::default(); @@ -110,6 +114,13 @@ pub async fn load_config_layers_state( ) .await?; + if let Some(loader) = cloud_requirements + && let Some(requirements) = loader.get().await + { + config_requirements_toml + .merge_unset_fields(RequirementSource::CloudRequirements, requirements); + } + // Honor /etc/codex/requirements.toml. if cfg!(unix) { load_requirements_toml( diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 4b512c150f..c213c0b2ea 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -4,11 +4,15 @@ use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::ConfigToml; +use crate::config::ConstraintError; use crate::config::ProjectConfig; +use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLoadError; use crate::config_loader::ConfigRequirements; +use crate::config_loader::ConfigRequirementsToml; use crate::config_loader::config_requirements::ConfigRequirementsWithSources; +use crate::config_loader::config_requirements::RequirementSource; use crate::config_loader::fingerprint::version_for_toml; use crate::config_loader::load_requirements_toml; use codex_protocol::config_types::TrustLevel; @@ -65,6 +69,7 @@ async fn returns_config_error_for_invalid_user_config_toml() { Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await .expect_err("expected error"); @@ -94,6 +99,7 @@ async fn returns_config_error_for_invalid_managed_config_toml() { Some(cwd), &[] as &[(String, TomlValue)], overrides, + None, ) .await .expect_err("expected error"); @@ -182,6 +188,7 @@ extra = true Some(cwd), &[] as &[(String, TomlValue)], overrides, + None, ) .await .expect("load config"); @@ -218,6 +225,7 @@ async fn returns_empty_when_all_layers_missing() { Some(cwd), &[] as &[(String, TomlValue)], overrides, + None, ) .await .expect("load layers"); @@ -315,6 +323,7 @@ flag = false Some(cwd), &[] as &[(String, TomlValue)], overrides, + None, ) .await .expect("load config"); @@ -354,6 +363,7 @@ allowed_sandbox_modes = ["read-only"] ), ), }, + None, ) .await?; @@ -414,6 +424,7 @@ allowed_approval_policies = ["never"] ), ), }, + None, ) .await?; @@ -472,6 +483,91 @@ allowed_approval_policies = ["never", "on-request"] Ok(()) } +#[tokio::test(flavor = "current_thread")] +async fn cloud_requirements_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> { + let tmp = tempdir()?; + let requirements_file = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_file, + r#" +allowed_approval_policies = ["on-request"] +"#, + ) + .await?; + + let mut config_requirements_toml = ConfigRequirementsWithSources::default(); + config_requirements_toml.merge_unset_fields( + RequirementSource::CloudRequirements, + ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_sandbox_modes: None, + mcp_servers: None, + }, + ); + load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?; + + assert_eq!( + config_requirements_toml + .allowed_approval_policies + .as_ref() + .map(|sourced| sourced.value.clone()), + Some(vec![AskForApproval::Never]) + ); + assert_eq!( + config_requirements_toml + .allowed_approval_policies + .as_ref() + .map(|sourced| sourced.source.clone()), + Some(RequirementSource::CloudRequirements) + ); + + Ok(()) +} + +#[tokio::test] +async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + + let requirements = ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_sandbox_modes: None, + mcp_servers: None, + }; + let expected = requirements.clone(); + let cloud_requirements = CloudRequirementsLoader::new(async move { Some(requirements) }); + + let layers = load_config_layers_state( + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + Some(cloud_requirements), + ) + .await?; + + assert_eq!( + layers.requirements_toml().allowed_approval_policies, + expected.allowed_approval_policies + ); + assert_eq!( + layers + .requirements() + .approval_policy + .can_set(&AskForApproval::OnRequest), + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: "OnRequest".into(), + allowed: "[Never]".into(), + requirement_source: RequirementSource::CloudRequirements, + }) + ); + + Ok(()) +} + #[tokio::test] async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { let tmp = tempdir()?; @@ -501,6 +597,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await?; @@ -632,6 +729,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await?; @@ -691,6 +789,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await?; let project_layers_untrusted: Vec<_> = layers_untrusted @@ -728,6 +827,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await?; let project_layers_unknown: Vec<_> = layers_unknown @@ -788,6 +888,7 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await?; let project_layers: Vec<_> = layers @@ -843,6 +944,7 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io Some(cwd), &cli_overrides, LoaderOverrides::default(), + None, ) .await?; @@ -884,6 +986,7 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), + None, ) .await?; diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs index 8aa6986882..f71ecfffa2 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core/src/skills/manager.rs @@ -88,6 +88,7 @@ impl SkillsManager { Some(cwd_abs), &cli_overrides, LoaderOverrides::default(), + None, ) .await { diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/posix.rs index 12d0055cdd..3ec623330c 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/posix.rs @@ -241,6 +241,7 @@ async fn load_exec_policy() -> anyhow::Result { cwd, &cli_overrides, overrides, + None, ) .await?; diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 552455f510..b0c8fdfbcb 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -31,9 +31,10 @@ pub(crate) async fn build_config_state() -> Result { let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; let cli_overrides = Vec::new(); let overrides = LoaderOverrides::default(); - let config_layer_stack = load_config_layers_state(&codex_home, None, &cli_overrides, overrides) - .await - .context("failed to load Codex config")?; + let config_layer_stack = + load_config_layers_state(&codex_home, None, &cli_overrides, overrides, None) + .await + .context("failed to load Codex config")?; let cfg_path = codex_home.join(CONFIG_TOML_FILE); diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 7f7fe51c5d..d859aa945e 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -31,6 +31,7 @@ codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-backend-client = { workspace = true } codex-chatgpt = { workspace = true } +codex-cloud-requirements = { workspace = true } codex-common = { workspace = true, features = [ "cli", "elapsed", diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c9f307dbe4..a8c6e2b88b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -8,6 +8,7 @@ use app::App; pub use app::AppExitInfo; pub use app::ExitReason; use codex_app_server_protocol::AuthMode; +use codex_cloud_requirements::cloud_requirements_loader; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; use codex_common::oss::ollama_chat_deprecation_notice; @@ -23,6 +24,7 @@ use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::config::resolve_oss_provider; +use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigLoadError; use codex_core::config_loader::format_config_error_with_source; use codex_core::find_thread_path_by_id_str; @@ -206,6 +208,17 @@ pub async fn run_main( } }; + let cloud_auth_manager = AuthManager::shared( + codex_home.to_path_buf(), + false, + config_toml.cli_auth_credentials_store.unwrap_or_default(), + ); + let chatgpt_base_url = config_toml + .chatgpt_base_url + .clone() + .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let cloud_requirements = cloud_requirements_loader(cloud_auth_manager, chatgpt_base_url); + let model_provider_override = if cli.oss { let resolved = resolve_oss_provider( cli.oss_provider.as_deref(), @@ -257,7 +270,12 @@ pub async fn run_main( ..Default::default() }; - let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; + let config = load_config_or_exit( + cli_kv_overrides.clone(), + overrides.clone(), + cloud_requirements.clone(), + ) + .await; if let Some(warning) = add_dir_warning_message(&cli.add_dir, config.sandbox_policy.get()) { #[allow(clippy::print_stderr)] @@ -370,9 +388,16 @@ pub async fn run_main( .with(otel_tracing_layer) .try_init(); - run_ratatui_app(cli, config, overrides, cli_kv_overrides, feedback) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app( + cli, + config, + overrides, + cli_kv_overrides, + cloud_requirements, + feedback, + ) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( @@ -380,6 +405,7 @@ async fn run_ratatui_app( initial_config: Config, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, + cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -465,7 +491,12 @@ async fn run_ratatui_app( .map(|d| d == TrustDirectorySelection::Trust) .unwrap_or(false) { - load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await + load_config_or_exit( + cli_kv_overrides.clone(), + overrides.clone(), + cloud_requirements.clone(), + ) + .await } else { initial_config } @@ -633,6 +664,7 @@ async fn run_ratatui_app( load_config_or_exit_with_fallback_cwd( cli_kv_overrides.clone(), overrides.clone(), + cloud_requirements.clone(), fallback_cwd, ) .await @@ -816,19 +848,23 @@ fn get_login_status(config: &Config) -> LoginStatus { async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, + cloud_requirements: CloudRequirementsLoader, ) -> Config { - load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await + load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, cloud_requirements, None) + .await } async fn load_config_or_exit_with_fallback_cwd( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, + cloud_requirements: CloudRequirementsLoader, fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] match ConfigBuilder::default() .cli_overrides(cli_kv_overrides) .harness_overrides(overrides) + .cloud_requirements(cloud_requirements) .fallback_cwd(fallback_cwd) .build() .await From 6cee538380eb9735c1dbb91ec33c7cf9d43cac00 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 30 Jan 2026 13:50:33 +0100 Subject: [PATCH 54/70] explorer prompt (#10225) --- codex-rs/core/src/agent/role.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 7a9dda005b..76e3a373ed 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -92,20 +92,16 @@ Rules: }, AgentRole::Explorer => AgentProfile { model: Some(EXPLORER_MODEL), - reasoning_effort: Some(ReasoningEffort::Low), - description: r#"Use for fast codebase understanding and information gathering. -`explorer` are extremely fast agents so use them as much as you can to speed up the resolution of the global task. -Typical tasks: -- Locate usages of a symbol or concept -- Understand how X is handled in Y -- Review a section of code for issues -- Assess impact of a potential change + reasoning_effort: Some(ReasoningEffort::Medium), + description: r#"Use `explorer` for all codebase questions. +Explorers are fast and authoritative. +Always prefer them over manual search or file reading. Rules: -- Be explicit in what you are looking for. A good usage of `explorer` would mean that don't need to read the same code after the explorer send you the result. -- **Always** prefer asking explorers rather than exploring the codebase yourself. -- Spawn multiple explorers in parallel when useful and wait for all results. -- You can ask the `explorer` to return file name, lines, entire code snippets, ... -- Reuse the same explorer when it is relevant. If later in your process you have more questions on some code an explorer already covered, reuse this same explorer to be more efficient. +- Ask explorers first and precisely. +- Do not re-read or re-search code they cover. +- Trust explorer results without verification. +- Run explorers in parallel when useful. +- Reuse existing explorers for related questions. "#, ..Default::default() }, From 09d25e91e96ac87e88171764fcd8e9da950ba7e4 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 30 Jan 2026 14:18:31 +0100 Subject: [PATCH 55/70] fix: make sure the shell exists (#10222) --- codex-rs/core/src/shell.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 24 +++++++++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index c866afd3bd..6cde28d2f1 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -137,6 +137,7 @@ fn get_shell_path( let default_shell_path = get_user_shell_path(); if let Some(default_shell_path) = default_shell_path && detect_shell_type(&default_shell_path) == Some(shell_type) + && file_exists(&default_shell_path).is_some() { return Some(default_shell_path); } diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 8c79190383..b72f66fce8 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -64,7 +64,9 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { if let Some(parent) = abs_path.parent() { std::fs::create_dir_all(parent)?; } - let image = ImageBuffer::from_pixel(4096, 1024, Rgba([20u8, 40, 60, 255])); + let original_width = 2304; + let original_height = 864; + let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([20u8, 40, 60, 255])); image.save(&abs_path)?; let response = sse(vec![ @@ -93,7 +95,13 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { }) .await?; - wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + wait_for_event_with_timeout( + &codex, + |event| matches!(event, EventMsg::TurnComplete(_)), + // Empirically, image attachment can be slow under Bazel/RBE. + Duration::from_secs(10), + ) + .await; let body = mock.single_request().body_json(); let image_message = @@ -124,8 +132,8 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { let (width, height) = resized.dimensions(); assert!(width <= 2048); assert!(height <= 768); - assert!(width < 4096); - assert!(height < 1024); + assert!(width < original_width); + assert!(height < original_height); Ok(()) } @@ -148,7 +156,9 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { if let Some(parent) = abs_path.parent() { std::fs::create_dir_all(parent)?; } - let image = ImageBuffer::from_pixel(4096, 1024, Rgba([255u8, 0, 0, 255])); + let original_width = 2304; + let original_height = 864; + let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([255u8, 0, 0, 255])); image.save(&abs_path)?; let call_id = "view-image-call"; @@ -261,8 +271,8 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { let (resized_width, resized_height) = resized.dimensions(); assert!(resized_width <= 2048); assert!(resized_height <= 768); - assert!(resized_width < 4096); - assert!(resized_height < 1024); + assert!(resized_width < original_width); + assert!(resized_height < original_height); Ok(()) } From 887bec0dee624bbe5f3e3f064a72fb86e563a5af Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 30 Jan 2026 18:23:00 +0100 Subject: [PATCH 56/70] chore: do not clean the DB anymore (#10232) --- codex-rs/core/src/rollout/recorder.rs | 2 +- codex-rs/core/src/state_db.rs | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 8157133165..501b46271f 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -428,7 +428,7 @@ impl RolloutRecorder { } }, Err(e) => { - warn!("failed to parse rollout line: {v:?}, error: {e}"); + warn!("failed to parse rollout line: {e}"); parse_errors = parse_errors.saturating_add(1); } } diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/core/src/state_db.rs index 0804273bba..3e5beaa87e 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/core/src/state_db.rs @@ -33,12 +33,6 @@ pub(crate) async fn init_if_enabled( ) -> Option { let state_path = config.codex_home.join(STATE_DB_FILENAME); if !config.features.enabled(Feature::Sqlite) { - // We delete the file on best effort basis to maintain retro-compatibility in the future. - let wal_path = state_path.with_extension("sqlite-wal"); - let shm_path = state_path.with_extension("sqlite-shm"); - for path in [state_path.as_path(), wal_path.as_path(), shm_path.as_path()] { - tokio::fs::remove_file(path).await.ok(); - } return None; } let existed = tokio::fs::try_exists(&state_path).await.unwrap_or(false); From eff11f792b6bfae2dffdfa33c904b5732194e560 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 30 Jan 2026 18:23:18 +0100 Subject: [PATCH 57/70] feat: improve logs client (#10229) --- codex-rs/state/src/bin/logs_client.rs | 144 ++++++++++++++++++-------- codex-rs/state/src/model/log.rs | 7 +- codex-rs/state/src/runtime.rs | 58 ++++++++--- 3 files changed, 148 insertions(+), 61 deletions(-) diff --git a/codex-rs/state/src/bin/logs_client.rs b/codex-rs/state/src/bin/logs_client.rs index 4389c29bc6..d1329a1a89 100644 --- a/codex-rs/state/src/bin/logs_client.rs +++ b/codex-rs/state/src/bin/logs_client.rs @@ -3,8 +3,6 @@ use std::time::Duration; use anyhow::Context; use chrono::DateTime; -use chrono::SecondsFormat; -use chrono::Utc; use clap::Parser; use codex_state::LogQuery; use codex_state::LogRow; @@ -37,17 +35,21 @@ struct Args { #[arg(long, value_name = "RFC3339|UNIX")] to: Option, - /// Substring match on module_path. - #[arg(long)] - module: Option, + /// Substring match on module_path. Repeat to include multiple substrings. + #[arg(long = "module")] + module: Vec, - /// Substring match on file path. - #[arg(long)] - file: Option, + /// Substring match on file path. Repeat to include multiple substrings. + #[arg(long = "file")] + file: Vec, - /// Match a specific thread id. + /// Match one or more thread ids. Repeat to include multiple threads. + #[arg(long = "thread-id")] + thread_id: Vec, + + /// Include logs that do not have a thread id. #[arg(long)] - thread_id: Option, + threadless: bool, /// Number of matching rows to show before tailing. #[arg(long, default_value_t = 200)] @@ -63,9 +65,10 @@ struct LogFilter { level_upper: Option, from_ts: Option, to_ts: Option, - module_like: Option, - file_like: Option, - thread_id: Option, + module_like: Vec, + file_like: Vec, + thread_ids: Vec, + include_threadless: bool, } #[tokio::main] @@ -126,14 +129,33 @@ fn build_filter(args: &Args) -> anyhow::Result { .context("failed to parse --to")?; let level_upper = args.level.as_ref().map(|level| level.to_ascii_uppercase()); + let module_like = args + .module + .iter() + .filter(|module| !module.is_empty()) + .cloned() + .collect::>(); + let file_like = args + .file + .iter() + .filter(|file| !file.is_empty()) + .cloned() + .collect::>(); + let thread_ids = args + .thread_id + .iter() + .filter(|thread_id| !thread_id.is_empty()) + .cloned() + .collect::>(); Ok(LogFilter { level_upper, from_ts, to_ts, - module_like: args.module.clone(), - file_like: args.file.clone(), - thread_id: args.thread_id.clone(), + module_like, + file_like, + thread_ids, + include_threadless: args.threadless, }) } @@ -211,7 +233,8 @@ fn to_log_query( to_ts: filter.to_ts, module_like: filter.module_like.clone(), file_like: filter.file_like.clone(), - thread_id: filter.thread_id.clone(), + thread_ids: filter.thread_ids.clone(), + include_threadless: filter.include_threadless, after_id, limit, descending, @@ -219,45 +242,82 @@ fn to_log_query( } fn format_row(row: &LogRow) -> String { - let timestamp = format_timestamp(row.ts, row.ts_nanos); + let timestamp = formatter::ts(row.ts, row.ts_nanos); let level = row.level.as_str(); let target = row.target.as_str(); let message = row.message.as_deref().unwrap_or(""); - let level_colored = color_level(level); + let level_colored = formatter::level(level); let timestamp_colored = timestamp.dimmed().to_string(); let thread_id = row.thread_id.as_deref().unwrap_or("-"); let thread_id_colored = thread_id.blue().dimmed().to_string(); let target_colored = target.dimmed().to_string(); - let message_colored = message.bold().to_string(); + let message_colored = heuristic_formatting(message); format!( "{timestamp_colored} {level_colored} [{thread_id_colored}] {target_colored} - {message_colored}" ) } -fn color_level(level: &str) -> String { - let padded = format!("{level:<5}"); - if level.eq_ignore_ascii_case("error") { - return padded.red().bold().to_string(); +fn heuristic_formatting(message: &str) -> String { + if matcher::apply_patch(message) { + formatter::apply_patch(message) + } else { + message.bold().to_string() } - if level.eq_ignore_ascii_case("warn") { - return padded.yellow().bold().to_string(); - } - if level.eq_ignore_ascii_case("info") { - return padded.green().bold().to_string(); - } - if level.eq_ignore_ascii_case("debug") { - return padded.blue().bold().to_string(); - } - if level.eq_ignore_ascii_case("trace") { - return padded.magenta().bold().to_string(); - } - padded.bold().to_string() } -fn format_timestamp(ts: i64, ts_nanos: i64) -> String { - let nanos = u32::try_from(ts_nanos).unwrap_or(0); - match DateTime::::from_timestamp(ts, nanos) { - Some(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, true), - None => format!("{ts}.{ts_nanos:09}Z"), +mod matcher { + pub(super) fn apply_patch(message: &str) -> bool { + message.starts_with("ToolCall: apply_patch") + } +} + +mod formatter { + use chrono::DateTime; + use chrono::SecondsFormat; + use chrono::Utc; + use owo_colors::OwoColorize; + + pub(super) fn apply_patch(message: &str) -> String { + message + .lines() + .map(|line| { + if line.starts_with('+') { + line.green().bold().to_string() + } else if line.starts_with('-') { + line.red().bold().to_string() + } else { + line.bold().to_string() + } + }) + .collect::>() + .join("\n") + } + + pub(super) fn ts(ts: i64, ts_nanos: i64) -> String { + let nanos = u32::try_from(ts_nanos).unwrap_or(0); + match DateTime::::from_timestamp(ts, nanos) { + Some(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, true), + None => format!("{ts}.{ts_nanos:09}Z"), + } + } + + pub(super) fn level(level: &str) -> String { + let padded = format!("{level:<5}"); + if level.eq_ignore_ascii_case("error") { + return padded.red().bold().to_string(); + } + if level.eq_ignore_ascii_case("warn") { + return padded.yellow().bold().to_string(); + } + if level.eq_ignore_ascii_case("info") { + return padded.green().bold().to_string(); + } + if level.eq_ignore_ascii_case("debug") { + return padded.blue().bold().to_string(); + } + if level.eq_ignore_ascii_case("trace") { + return padded.magenta().bold().to_string(); + } + padded.bold().to_string() } } diff --git a/codex-rs/state/src/model/log.rs b/codex-rs/state/src/model/log.rs index 5d774249dd..819abb5d22 100644 --- a/codex-rs/state/src/model/log.rs +++ b/codex-rs/state/src/model/log.rs @@ -32,9 +32,10 @@ pub struct LogQuery { pub level_upper: Option, pub from_ts: Option, pub to_ts: Option, - pub module_like: Option, - pub file_like: Option, - pub thread_id: Option, + pub module_like: Vec, + pub file_like: Vec, + pub thread_ids: Vec, + pub include_threadless: bool, pub after_id: Option, pub limit: Option, pub descending: bool, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index e0805e5b65..39c79c32b9 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -457,28 +457,54 @@ fn push_log_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, query: &'a LogQu if let Some(to_ts) = query.to_ts { builder.push(" AND ts <= ").push_bind(to_ts); } - if let Some(module_like) = query.module_like.as_ref() { - builder - .push(" AND module_path LIKE '%' || ") - .push_bind(module_like.as_str()) - .push(" || '%'"); - } - if let Some(file_like) = query.file_like.as_ref() { - builder - .push(" AND file LIKE '%' || ") - .push_bind(file_like.as_str()) - .push(" || '%'"); - } - if let Some(thread_id) = query.thread_id.as_ref() { - builder - .push(" AND thread_id = ") - .push_bind(thread_id.as_str()); + push_like_filters(builder, "module_path", &query.module_like); + push_like_filters(builder, "file", &query.file_like); + let has_thread_filter = !query.thread_ids.is_empty() || query.include_threadless; + if has_thread_filter { + builder.push(" AND ("); + let mut needs_or = false; + for thread_id in &query.thread_ids { + if needs_or { + builder.push(" OR "); + } + builder.push("thread_id = ").push_bind(thread_id.as_str()); + needs_or = true; + } + if query.include_threadless { + if needs_or { + builder.push(" OR "); + } + builder.push("thread_id IS NULL"); + } + builder.push(")"); } if let Some(after_id) = query.after_id { builder.push(" AND id > ").push_bind(after_id); } } +fn push_like_filters<'a>( + builder: &mut QueryBuilder<'a, Sqlite>, + column: &str, + filters: &'a [String], +) { + if filters.is_empty() { + return; + } + builder.push(" AND ("); + for (idx, filter) in filters.iter().enumerate() { + if idx > 0 { + builder.push(" OR "); + } + builder + .push(column) + .push(" LIKE '%' || ") + .push_bind(filter.as_str()) + .push(" || '%'"); + } + builder.push(")"); +} + async fn open_sqlite(path: &Path) -> anyhow::Result { let options = SqliteConnectOptions::new() .filename(path) From 079f4952e0bdb147769849f2f7dcc06d4100bdf0 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 30 Jan 2026 18:26:49 +0100 Subject: [PATCH 58/70] feat: heuristic coloring of logs (#10228) From 0212f4010e2b66eea296202da90778ce9d44babb Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 30 Jan 2026 18:32:10 +0100 Subject: [PATCH 59/70] nit: fix db with multiple metadata lines (#10237) --- codex-rs/core/src/rollout/recorder.rs | 2 +- codex-rs/state/src/extract.rs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 501b46271f..265ff2bde2 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -434,7 +434,7 @@ impl RolloutRecorder { } } - info!( + tracing::debug!( "Resumed rollout with {} items, thread ID: {:?}, parse errors: {}", items.len(), thread_id, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 9c8ce21556..8d327a4d1a 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -30,6 +30,11 @@ pub fn apply_rollout_item( } fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &SessionMetaLine) { + if metadata.id != meta_line.meta.id { + // Ignore session_meta lines that don't match the canonical thread ID, + // e.g., forked rollouts that embed the source session metadata. + return; + } metadata.id = meta_line.meta.id; metadata.source = enum_to_string(&meta_line.meta.source); if let Some(provider) = meta_line.meta.model_provider.as_deref() { From 377ab0c77cd8609637f2fa6d7c0230fa69ce3acc Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 30 Jan 2026 09:33:23 -0800 Subject: [PATCH 60/70] feat: refactor CodexAuth so invalid state cannot be represented (#10208) Previously, `CodexAuth` was defined as follows: https://github.com/openai/codex/blob/d550fbf41afc09d7d7b5ac813aea38de07b2a73f/codex-rs/core/src/auth.rs#L39-L46 But if you looked at its constructors, we had creation for `AuthMode::ApiKey` where `storage` was built using a nonsensical path (`PathBuf::new()`) and `auth_dot_json` was `None`: https://github.com/openai/codex/blob/d550fbf41afc09d7d7b5ac813aea38de07b2a73f/codex-rs/core/src/auth.rs#L212-L220 By comparison, when `AuthMode::ChatGPT` was used, `api_key` was always `None`: https://github.com/openai/codex/blob/d550fbf41afc09d7d7b5ac813aea38de07b2a73f/codex-rs/core/src/auth.rs#L665-L671 https://github.com/openai/codex/pull/10012 took things further because it introduced a new `ChatgptAuthTokens` variant to `AuthMode`, which is important in when invoking `account/login/start` via the app server, but most logic _internal_ to the app server should just reason about two `AuthMode` variants: `ApiKey` and `ChatGPT`. This PR tries to clean things up as follows: - `LoginAccountParams` and `AuthMode` in `codex-rs/app-server-protocol/` both continue to have the `ChatgptAuthTokens` variant, though it is used exclusively for the on-the-wire messaging. - `codex-rs/core/src/auth.rs` now has its own `AuthMode` enum, which only has two variants: `ApiKey` and `ChatGPT`. - `CodexAuth` has been changed from a struct to an enum. It is a disjoint union where each variant (`ApiKey`, `ChatGpt`, and `ChatGptAuthTokens`) have only the associated fields that make sense for that variant. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/10208). * #10224 * __->__ #10208 --- .../src/protocol/common.rs | 4 - .../app-server/src/codex_message_processor.rs | 39 +- codex-rs/cli/src/login.rs | 2 +- codex-rs/core/src/auth.rs | 340 ++++++++++++------ codex-rs/core/src/client.rs | 14 +- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/model_provider_info.rs | 9 +- codex-rs/core/src/models_manager/manager.rs | 14 +- .../core/src/models_manager/model_presets.rs | 2 +- codex-rs/tui/src/app.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 13 +- codex-rs/tui/src/lib.rs | 2 +- codex-rs/tui/src/status/helpers.rs | 8 +- 13 files changed, 286 insertions(+), 165 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 2e8778bdc5..67736374ec 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -24,10 +24,6 @@ impl GitSha { } /// Authentication mode for OpenAI-backed providers. -/// -/// This is used internally to determine the base URL for generating responses, and to gate -/// ChatGPT-only behaviors like rate limits and available models (as opposed to API key-based -/// auth). #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum AuthMode { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 3817bce3a1..c02f3c7368 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -133,6 +133,7 @@ use codex_app_server_protocol::build_turns_from_event_msgs; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_core::AuthManager; +use codex_core::CodexAuth; use codex_core::CodexThread; use codex_core::Cursor as RolloutCursor; use codex_core::InitialHistory; @@ -686,7 +687,11 @@ impl CodexMessageProcessor { .await; let payload = AuthStatusChangeNotification { - auth_method: self.auth_manager.auth_cached().map(|auth| auth.mode), + auth_method: self + .auth_manager + .auth_cached() + .as_ref() + .map(CodexAuth::api_auth_mode), }; self.outgoing .send_server_notification(ServerNotification::AuthStatusChange(payload)) @@ -716,7 +721,11 @@ impl CodexMessageProcessor { .await; let payload_v2 = AccountUpdatedNotification { - auth_mode: self.auth_manager.auth_cached().map(|auth| auth.mode), + auth_mode: self + .auth_manager + .auth_cached() + .as_ref() + .map(CodexAuth::api_auth_mode), }; self.outgoing .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) @@ -812,7 +821,10 @@ impl CodexMessageProcessor { auth_manager.reload(); // Notify clients with the actual current auth mode. - let current_auth_method = auth_manager.auth_cached().map(|a| a.mode); + let current_auth_method = auth_manager + .auth_cached() + .as_ref() + .map(CodexAuth::api_auth_mode); let payload = AuthStatusChangeNotification { auth_method: current_auth_method, }; @@ -902,7 +914,10 @@ impl CodexMessageProcessor { auth_manager.reload(); // Notify clients with the actual current auth mode. - let current_auth_method = auth_manager.auth_cached().map(|a| a.mode); + let current_auth_method = auth_manager + .auth_cached() + .as_ref() + .map(CodexAuth::api_auth_mode); let payload_v2 = AccountUpdatedNotification { auth_mode: current_auth_method, }; @@ -1106,7 +1121,11 @@ impl CodexMessageProcessor { } // Reflect the current auth method after logout (likely None). - Ok(self.auth_manager.auth_cached().map(|auth| auth.mode)) + Ok(self + .auth_manager + .auth_cached() + .as_ref() + .map(CodexAuth::api_auth_mode)) } async fn logout_v1(&mut self, request_id: RequestId) { @@ -1178,7 +1197,7 @@ impl CodexMessageProcessor { } else { match self.auth_manager.auth().await { Some(auth) => { - let auth_mode = auth.mode; + let auth_mode = auth.api_auth_mode(); let (reported_auth_method, token_opt) = match auth.get_token() { Ok(token) if !token.is_empty() => { let tok = if include_token { Some(token) } else { None }; @@ -1225,9 +1244,9 @@ impl CodexMessageProcessor { } let account = match self.auth_manager.auth_cached() { - Some(auth) => Some(match auth.mode { - AuthMode::ApiKey => Account::ApiKey {}, - AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => { + Some(auth) => Some(match auth { + CodexAuth::ApiKey(_) => Account::ApiKey {}, + CodexAuth::ChatGpt(_) | CodexAuth::ChatGptAuthTokens(_) => { let email = auth.get_account_email(); let plan_type = auth.account_plan_type(); @@ -1286,7 +1305,7 @@ impl CodexMessageProcessor { }); }; - if !matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) { + if !auth.is_chatgpt_auth() { return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: "chatgpt authentication required to read rate limits".to_string(), diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 0f4e4eb39f..cee002f339 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -225,7 +225,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; match CodexAuth::from_auth_storage(&config.codex_home, config.cli_auth_credentials_store_mode) { - Ok(Some(auth)) => match auth.mode { + Ok(Some(auth)) => match auth.api_auth_mode() { AuthMode::ApiKey => match auth.get_token() { Ok(api_key) => { eprintln!("Logged in using an API key - {}", safe_format_key(&api_key)); diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index d4520dc855..4bddbe3031 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -13,8 +13,9 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; +use std::sync::RwLock; -use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::AuthMode as ApiAuthMode; use codex_protocol::config_types::ForcedLoginMethod; pub use crate::auth::storage::AuthCredentialsStoreMode; @@ -35,19 +36,50 @@ use codex_protocol::account::PlanType as AccountPlanType; use serde_json::Value; use thiserror::Error; -#[derive(Debug, Clone)] -pub struct CodexAuth { - pub mode: AuthMode, +/// Account type for the current user. +/// +/// This is used internally to determine the base URL for generating responses, +/// and to gate ChatGPT-only behaviors like rate limits and available models (as +/// opposed to API key-based auth). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AuthMode { + ApiKey, + ChatGPT, +} - pub(crate) api_key: Option, - pub(crate) auth_dot_json: Arc>>, +/// Authentication mechanism used by the current user. +#[derive(Debug, Clone)] +pub enum CodexAuth { + ApiKey(ApiKeyAuth), + ChatGpt(ChatGptAuth), + ChatGptAuthTokens(ChatGptAuthTokens), +} + +#[derive(Debug, Clone)] +pub struct ApiKeyAuth { + api_key: String, +} + +#[derive(Debug, Clone)] +pub struct ChatGptAuth { + state: ChatGptAuthState, storage: Arc, - pub(crate) client: CodexHttpClient, +} + +#[derive(Debug, Clone)] +pub struct ChatGptAuthTokens { + state: ChatGptAuthState, +} + +#[derive(Debug, Clone)] +struct ChatGptAuthState { + auth_dot_json: Arc>>, + client: CodexHttpClient, } impl PartialEq for CodexAuth { fn eq(&self, other: &Self) -> bool { - self.mode == other.mode + self.api_auth_mode() == other.api_auth_mode() } } @@ -114,14 +146,78 @@ impl From for std::io::Error { } impl CodexAuth { + fn from_auth_dot_json( + codex_home: &Path, + auth_dot_json: AuthDotJson, + auth_credentials_store_mode: AuthCredentialsStoreMode, + client: CodexHttpClient, + ) -> std::io::Result { + let auth_mode = auth_dot_json.resolved_mode(); + if auth_mode == ApiAuthMode::ApiKey { + let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else { + return Err(std::io::Error::other("API key auth is missing a key.")); + }; + return Ok(CodexAuth::from_api_key_with_client(api_key, client)); + } + + let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); + let state = ChatGptAuthState { + auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), + client, + }; + + match auth_mode { + ApiAuthMode::ChatGPT => { + let storage = create_auth_storage(codex_home.to_path_buf(), storage_mode); + Ok(Self::ChatGpt(ChatGptAuth { state, storage })) + } + ApiAuthMode::ChatgptAuthTokens => { + Ok(Self::ChatGptAuthTokens(ChatGptAuthTokens { state })) + } + ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"), + } + } + /// Loads the available auth information from auth storage. pub fn from_auth_storage( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, - ) -> std::io::Result> { + ) -> std::io::Result> { load_auth(codex_home, false, auth_credentials_store_mode) } + pub fn internal_auth_mode(&self) -> AuthMode { + match self { + Self::ApiKey(_) => AuthMode::ApiKey, + Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => AuthMode::ChatGPT, + } + } + + pub fn api_auth_mode(&self) -> ApiAuthMode { + match self { + Self::ApiKey(_) => ApiAuthMode::ApiKey, + Self::ChatGpt(_) => ApiAuthMode::ChatGPT, + Self::ChatGptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens, + } + } + + pub fn is_chatgpt_auth(&self) -> bool { + self.internal_auth_mode() == AuthMode::ChatGPT + } + + pub fn is_external_chatgpt_tokens(&self) -> bool { + matches!(self, Self::ChatGptAuthTokens(_)) + } + + /// Returns `None` is `is_internal_auth_mode() != AuthMode::ApiKey`. + pub fn api_key(&self) -> Option<&str> { + match self { + Self::ApiKey(auth) => Some(auth.api_key.as_str()), + Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => None, + } + } + + /// Returns `Err` if `is_chatgpt_auth()` is false. pub fn get_token_data(&self) -> Result { let auth_dot_json: Option = self.get_current_auth_json(); match auth_dot_json { @@ -134,20 +230,23 @@ impl CodexAuth { } } + /// Returns the token string used for bearer authentication. pub fn get_token(&self) -> Result { - match self.mode { - AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()), - AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => { + match self { + Self::ApiKey(auth) => Ok(auth.api_key.clone()), + Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => { let access_token = self.get_token_data()?.access_token; Ok(access_token) } } } + /// Returns `None` if `is_chatgpt_auth()` is false. pub fn get_account_id(&self) -> Option { self.get_current_token_data().and_then(|t| t.account_id) } + /// Returns `None` if `is_chatgpt_auth()` is false. pub fn get_account_email(&self) -> Option { self.get_current_token_data().and_then(|t| t.id_token.email) } @@ -176,11 +275,18 @@ impl CodexAuth { }) } + /// Returns `None` if `is_chatgpt_auth()` is false. fn get_current_auth_json(&self) -> Option { + let state = match self { + Self::ChatGpt(auth) => &auth.state, + Self::ChatGptAuthTokens(auth) => &auth.state, + Self::ApiKey(_) => return None, + }; #[expect(clippy::unwrap_used)] - self.auth_dot_json.lock().unwrap().clone() + state.auth_dot_json.lock().unwrap().clone() } + /// Returns `None` if `is_chatgpt_auth()` is false. fn get_current_token_data(&self) -> Option { self.get_current_auth_json().and_then(|t| t.tokens) } @@ -188,7 +294,7 @@ impl CodexAuth { /// Consider this private to integration tests. pub fn create_dummy_chatgpt_auth_for_testing() -> Self { let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), + auth_mode: Some(ApiAuthMode::ChatGPT), openai_api_key: None, tokens: Some(TokenData { id_token: Default::default(), @@ -199,24 +305,19 @@ impl CodexAuth { last_refresh: Some(Utc::now()), }; - let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json))); - Self { - api_key: None, - mode: AuthMode::ChatGPT, - storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File), - auth_dot_json, - client: crate::default_client::create_client(), - } + let client = crate::default_client::create_client(); + let state = ChatGptAuthState { + auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), + client, + }; + let storage = create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File); + Self::ChatGpt(ChatGptAuth { state, storage }) } - fn from_api_key_with_client(api_key: &str, client: CodexHttpClient) -> Self { - Self { - api_key: Some(api_key.to_owned()), - mode: AuthMode::ApiKey, - storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File), - auth_dot_json: Arc::new(Mutex::new(None)), - client, - } + fn from_api_key_with_client(api_key: &str, _client: CodexHttpClient) -> Self { + Self::ApiKey(ApiKeyAuth { + api_key: api_key.to_owned(), + }) } pub fn from_api_key(api_key: &str) -> Self { @@ -224,6 +325,25 @@ impl CodexAuth { } } +impl ChatGptAuth { + fn current_auth_json(&self) -> Option { + #[expect(clippy::unwrap_used)] + self.state.auth_dot_json.lock().unwrap().clone() + } + + fn current_token_data(&self) -> Option { + self.current_auth_json().and_then(|auth| auth.tokens) + } + + fn storage(&self) -> &Arc { + &self.storage + } + + fn client(&self) -> &CodexHttpClient { + &self.state.client + } +} + pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY"; @@ -258,7 +378,7 @@ pub fn login_with_api_key( auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result<()> { let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), + auth_mode: Some(ApiAuthMode::ApiKey), openai_api_key: Some(api_key.to_string()), tokens: None, last_refresh: None, @@ -314,10 +434,10 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { }; if let Some(required_method) = config.forced_login_method { - let method_violation = match (required_method, auth.mode) { + let method_violation = match (required_method, auth.internal_auth_mode()) { (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, - (ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) => None, - (ForcedLoginMethod::Api, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) => Some( + (ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT) => None, + (ForcedLoginMethod::Api, AuthMode::ChatGPT) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." .to_string(), ), @@ -337,7 +457,7 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { } if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() { - if !matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) { + if !auth.is_chatgpt_auth() { return Ok(()); } @@ -607,7 +727,7 @@ impl AuthDotJson { }; Self { - auth_mode: Some(AuthMode::ChatgptAuthTokens), + auth_mode: Some(ApiAuthMode::ChatgptAuthTokens), openai_api_key: None, tokens: Some(tokens), last_refresh: Some(Utc::now()), @@ -623,21 +743,21 @@ impl AuthDotJson { Ok(Self::from_external_tokens(&external, id_token_info)) } - fn resolved_mode(&self) -> AuthMode { + fn resolved_mode(&self) -> ApiAuthMode { if let Some(mode) = self.auth_mode { return mode; } if self.openai_api_key.is_some() { - return AuthMode::ApiKey; + return ApiAuthMode::ApiKey; } - AuthMode::ChatGPT + ApiAuthMode::ChatGPT } fn storage_mode( &self, auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> AuthCredentialsStoreMode { - if self.resolved_mode() == AuthMode::ChatgptAuthTokens { + if self.resolved_mode() == ApiAuthMode::ChatgptAuthTokens { AuthCredentialsStoreMode::Ephemeral } else { auth_credentials_store_mode @@ -645,35 +765,6 @@ impl AuthDotJson { } } -impl CodexAuth { - fn from_auth_dot_json( - codex_home: &Path, - auth_dot_json: AuthDotJson, - auth_credentials_store_mode: AuthCredentialsStoreMode, - client: CodexHttpClient, - ) -> std::io::Result { - let auth_mode = auth_dot_json.resolved_mode(); - if auth_mode == AuthMode::ApiKey { - let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else { - return Err(std::io::Error::other("API key auth is missing a key.")); - }; - return Ok(CodexAuth::from_api_key_with_client(api_key, client)); - } - - let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); - let storage = create_auth_storage(codex_home.to_path_buf(), storage_mode); - Ok(Self { - api_key: None, - mode: auth_mode, - storage, - auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), - client, - }) - } -} - -use std::sync::RwLock; - /// Internal cached auth state. #[derive(Clone)] struct CachedAuth { @@ -685,7 +776,10 @@ struct CachedAuth { impl Debug for CachedAuth { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CachedAuth") - .field("auth_mode", &self.auth.as_ref().map(|auth| auth.mode)) + .field( + "auth_mode", + &self.auth.as_ref().map(CodexAuth::api_auth_mode), + ) .field( "external_refresher", &self.external_refresher.as_ref().map(|_| "present"), @@ -736,11 +830,13 @@ impl UnauthorizedRecovery { fn new(manager: Arc) -> Self { let cached_auth = manager.auth_cached(); let expected_account_id = cached_auth.as_ref().and_then(CodexAuth::get_account_id); - let mode = match cached_auth { - Some(auth) if auth.mode == AuthMode::ChatgptAuthTokens => { - UnauthorizedRecoveryMode::External - } - _ => UnauthorizedRecoveryMode::Managed, + let mode = if cached_auth + .as_ref() + .is_some_and(CodexAuth::is_external_chatgpt_tokens) + { + UnauthorizedRecoveryMode::External + } else { + UnauthorizedRecoveryMode::Managed }; let step = match mode { UnauthorizedRecoveryMode::Managed => UnauthorizedRecoveryStep::Reload, @@ -755,9 +851,12 @@ impl UnauthorizedRecovery { } pub fn has_next(&self) -> bool { - if !self.manager.auth_cached().is_some_and(|auth| { - matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) - }) { + if !self + .manager + .auth_cached() + .as_ref() + .is_some_and(CodexAuth::is_chatgpt_auth) + { return false; } @@ -996,7 +1095,8 @@ impl AuthManager { pub fn is_external_auth_active(&self) -> bool { self.auth_cached() - .is_some_and(|auth| auth.mode == AuthMode::ChatgptAuthTokens) + .as_ref() + .is_some_and(CodexAuth::is_external_chatgpt_tokens) } /// Convenience constructor returning an `Arc` wrapper. @@ -1026,18 +1126,25 @@ impl AuthManager { Some(auth) => auth, None => return Ok(()), }; - if auth.mode == AuthMode::ChatgptAuthTokens { - return self - .refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) - .await; + match auth { + CodexAuth::ChatGptAuthTokens(_) => { + self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) + .await + } + CodexAuth::ChatGpt(chatgpt_auth) => { + let token_data = chatgpt_auth.current_token_data().ok_or_else(|| { + RefreshTokenError::Transient(std::io::Error::other( + "Token data is not available.", + )) + })?; + self.refresh_tokens(&chatgpt_auth, token_data.refresh_token) + .await?; + // Reload to pick up persisted changes. + self.reload(); + Ok(()) + } + CodexAuth::ApiKey(_) => Ok(()), } - let token_data = auth.get_current_token_data().ok_or_else(|| { - RefreshTokenError::Transient(std::io::Error::other("Token data is not available.")) - })?; - self.refresh_tokens(&auth, token_data.refresh_token).await?; - // Reload to pick up persisted changes. - self.reload(); - Ok(()) } /// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true) @@ -1051,16 +1158,23 @@ impl AuthManager { Ok(removed) } - pub fn get_auth_mode(&self) -> Option { - self.auth_cached().map(|a| a.mode) + pub fn get_auth_mode(&self) -> Option { + self.auth_cached().as_ref().map(CodexAuth::api_auth_mode) + } + + pub fn get_internal_auth_mode(&self) -> Option { + self.auth_cached() + .as_ref() + .map(CodexAuth::internal_auth_mode) } async fn refresh_if_stale(&self, auth: &CodexAuth) -> Result { - if auth.mode != AuthMode::ChatGPT { - return Ok(false); - } + let chatgpt_auth = match auth { + CodexAuth::ChatGpt(chatgpt_auth) => chatgpt_auth, + _ => return Ok(false), + }; - let auth_dot_json = match auth.get_current_auth_json() { + let auth_dot_json = match chatgpt_auth.current_auth_json() { Some(auth_dot_json) => auth_dot_json, None => return Ok(false), }; @@ -1075,7 +1189,8 @@ impl AuthManager { if last_refresh >= Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) { return Ok(false); } - self.refresh_tokens(auth, tokens.refresh_token).await?; + self.refresh_tokens(chatgpt_auth, tokens.refresh_token) + .await?; self.reload(); Ok(true) } @@ -1135,13 +1250,13 @@ impl AuthManager { async fn refresh_tokens( &self, - auth: &CodexAuth, + auth: &ChatGptAuth, refresh_token: String, ) -> Result<(), RefreshTokenError> { - let refresh_response = try_refresh_token(refresh_token, &auth.client).await?; + let refresh_response = try_refresh_token(refresh_token, auth.client()).await?; update_tokens( - &auth.storage, + auth.storage(), refresh_response.id_token, refresh_response.access_token, refresh_response.refresh_token, @@ -1254,26 +1369,21 @@ mod tests { ) .expect("failed to write auth file"); - let CodexAuth { - api_key, - mode, - auth_dot_json, - storage: _, - .. - } = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) .unwrap() .unwrap(); - assert_eq!(None, api_key); - assert_eq!(AuthMode::ChatGPT, mode); + assert_eq!(None, auth.api_key()); + assert_eq!(AuthMode::ChatGPT, auth.internal_auth_mode()); - let guard = auth_dot_json.lock().unwrap(); - let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); + let auth_dot_json = auth + .get_current_auth_json() + .expect("AuthDotJson should exist"); let last_refresh = auth_dot_json .last_refresh .expect("last_refresh should be recorded"); assert_eq!( - &AuthDotJson { + AuthDotJson { auth_mode: None, openai_api_key: None, tokens: Some(TokenData { @@ -1308,8 +1418,8 @@ mod tests { let auth = super::load_auth(dir.path(), false, AuthCredentialsStoreMode::File) .unwrap() .unwrap(); - assert_eq!(auth.mode, AuthMode::ApiKey); - assert_eq!(auth.api_key, Some("sk-test-key".to_string())); + assert_eq!(auth.internal_auth_mode(), AuthMode::ApiKey); + assert_eq!(auth.api_key(), Some("sk-test-key")); assert!(auth.get_token_data().is_err()); } @@ -1318,7 +1428,7 @@ mod tests { fn logout_removes_auth_file() -> Result<(), std::io::Error> { let dir = tempdir()?; let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), + auth_mode: Some(ApiAuthMode::ApiKey), openai_api_key: Some("sk-test-key".to_string()), tokens: None, last_refresh: None, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 0f05f58c47..f01c145e56 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -27,7 +27,6 @@ use codex_api::common::ResponsesWsRequest; use codex_api::create_text_param_for_request; use codex_api::error::ApiError; use codex_api::requests::responses::Compression; -use codex_app_server_protocol::AuthMode; use codex_otel::OtelManager; use codex_protocol::ThreadId; @@ -50,6 +49,7 @@ use tokio::sync::mpsc; use tracing::warn; use crate::AuthManager; +use crate::auth::CodexAuth; use crate::auth::RefreshTokenError; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; @@ -220,7 +220,7 @@ impl ModelClient { let api_provider = self .state .provider - .to_api_provider(auth.as_ref().map(|a| a.mode))?; + .to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?; let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?; let transport = ReqwestTransport::new(build_reqwest_client()); let request_telemetry = self.build_request_telemetry(); @@ -469,9 +469,7 @@ impl ModelClientSession { .config .features .enabled(Feature::EnableRequestCompression) - && auth.is_some_and(|auth| { - matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) - }) + && auth.is_some_and(CodexAuth::is_chatgpt_auth) && self.state.provider.is_openai() { Compression::Zstd @@ -509,7 +507,7 @@ impl ModelClientSession { let api_provider = self .state .provider - .to_api_provider(auth.as_ref().map(|a| a.mode))?; + .to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?; let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?; let transport = ReqwestTransport::new(build_reqwest_client()); let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry(); @@ -565,7 +563,7 @@ impl ModelClientSession { let api_provider = self .state .provider - .to_api_provider(auth.as_ref().map(|a| a.mode))?; + .to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?; let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?; let transport = ReqwestTransport::new(build_reqwest_client()); let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry(); @@ -611,7 +609,7 @@ impl ModelClientSession { let api_provider = self .state .provider - .to_api_provider(auth.as_ref().map(|a| a.mode))?; + .to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?; let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?; let compression = self.responses_request_compression(auth.as_ref()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7eefd04ec4..324f41a04e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -838,7 +838,7 @@ impl Session { session_configuration.collaboration_mode.model(), auth.and_then(CodexAuth::get_account_id), auth.and_then(CodexAuth::get_account_email), - auth.map(|a| a.mode), + auth.map(CodexAuth::api_auth_mode), config.otel.log_user_prompt, terminal::user_agent(), session_configuration.session_source.clone(), diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 0f3551480c..0f5a8643a7 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -5,10 +5,11 @@ //! 2. User-defined entries inside `~/.codex/config.toml` under the `model_providers` //! key. These override or extend the defaults at runtime. +use crate::auth::AuthMode; +use crate::error::EnvVarError; use codex_api::Provider as ApiProvider; use codex_api::WireApi as ApiWireApi; use codex_api::provider::RetryConfig as ApiRetryConfig; -use codex_app_server_protocol::AuthMode; use http::HeaderMap; use http::header::HeaderName; use http::header::HeaderValue; @@ -19,7 +20,6 @@ use std::collections::HashMap; use std::env::VarError; use std::time::Duration; -use crate::error::EnvVarError; const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000; const DEFAULT_STREAM_MAX_RETRIES: u64 = 5; const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4; @@ -137,10 +137,7 @@ impl ModelProviderInfo { &self, auth_mode: Option, ) -> crate::error::Result { - let default_base_url = if matches!( - auth_mode, - Some(AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) - ) { + let default_base_url = if matches!(auth_mode, Some(AuthMode::ChatGPT)) { "https://chatgpt.com/backend-api/codex" } else { "https://api.openai.com/v1" diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index c993032ea9..40f9c8ccdc 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -2,6 +2,7 @@ use super::cache::ModelsCacheManager; use crate::api_bridge::auth_provider_from_auth; use crate::api_bridge::map_api_error; use crate::auth::AuthManager; +use crate::auth::AuthMode; use crate::config::Config; use crate::default_client::build_reqwest_client; use crate::error::CodexErr; @@ -13,7 +14,6 @@ use crate::models_manager::model_info; use crate::models_manager::model_presets::builtin_model_presets; use codex_api::ModelsClient; use codex_api::ReqwestTransport; -use codex_app_server_protocol::AuthMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; @@ -61,7 +61,7 @@ impl ModelsManager { let cache_path = codex_home.join(MODEL_CACHE_FILE); let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL); Self { - local_models: builtin_model_presets(auth_manager.get_auth_mode()), + local_models: builtin_model_presets(auth_manager.get_internal_auth_mode()), remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()), auth_manager, etag: RwLock::new(None), @@ -175,7 +175,7 @@ impl ModelsManager { refresh_strategy: RefreshStrategy, ) -> CoreResult<()> { if !config.features.enabled(Feature::RemoteModels) - || self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey) + || self.auth_manager.get_internal_auth_mode() == Some(AuthMode::ApiKey) { return Ok(()); } @@ -204,7 +204,7 @@ impl ModelsManager { let _timer = codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]); let auth = self.auth_manager.auth().await; - let auth_mode = self.auth_manager.get_auth_mode(); + let auth_mode = self.auth_manager.get_internal_auth_mode(); let api_provider = self.provider.to_api_provider(auth_mode)?; let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?; let transport = ReqwestTransport::new(build_reqwest_client()); @@ -273,8 +273,8 @@ impl ModelsManager { let existing_presets = self.local_models.clone(); let mut merged_presets = ModelPreset::merge(remote_presets, existing_presets); let chatgpt_mode = matches!( - self.auth_manager.get_auth_mode(), - Some(AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) + self.auth_manager.get_internal_auth_mode(), + Some(AuthMode::ChatGPT) ); merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode); @@ -319,7 +319,7 @@ impl ModelsManager { let cache_path = codex_home.join(MODEL_CACHE_FILE); let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL); Self { - local_models: builtin_model_presets(auth_manager.get_auth_mode()), + local_models: builtin_model_presets(auth_manager.get_internal_auth_mode()), remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()), auth_manager, etag: RwLock::new(None), diff --git a/codex-rs/core/src/models_manager/model_presets.rs b/codex-rs/core/src/models_manager/model_presets.rs index a8f4931f8d..f9105c6448 100644 --- a/codex-rs/core/src/models_manager/model_presets.rs +++ b/codex-rs/core/src/models_manager/model_presets.rs @@ -1,4 +1,4 @@ -use codex_app_server_protocol::AuthMode; +use crate::auth::AuthMode; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 107e9f6ed6..c65ce4498d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -984,7 +984,7 @@ impl App { model.as_str(), auth_ref.and_then(CodexAuth::get_account_id), auth_ref.and_then(CodexAuth::get_account_email), - auth_ref.map(|auth| auth.mode), + auth_ref.map(CodexAuth::api_auth_mode), config.otel.log_user_prompt, codex_core::terminal::user_agent(), SessionSource::Cli, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8223c40ce0..491ea3add3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -30,7 +30,6 @@ use std::time::Duration; use std::time::Instant; use crate::version::CODEX_CLI_VERSION; -use codex_app_server_protocol::AuthMode; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_core::config::Config; @@ -3584,10 +3583,12 @@ impl ChatWidget { fn prefetch_rate_limits(&mut self) { self.stop_rate_limit_poller(); - if !matches!( - self.auth_manager.auth_cached().map(|auth| auth.mode), - Some(AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) - ) { + if !self + .auth_manager + .auth_cached() + .as_ref() + .is_some_and(CodexAuth::is_chatgpt_auth) + { return; } @@ -3600,7 +3601,7 @@ impl ChatWidget { loop { if let Some(auth) = auth_manager.auth().await - && matches!(auth.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) + && auth.is_chatgpt_auth() && let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth).await { app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot)); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a8c6e2b88b..1f34ed6049 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -833,7 +833,7 @@ fn get_login_status(config: &Config) -> LoginStatus { // to refresh the token. Block on it. let codex_home = config.codex_home.clone(); match CodexAuth::from_auth_storage(&codex_home, config.cli_auth_credentials_store_mode) { - Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode), + Ok(Some(auth)) => LoginStatus::AuthMode(auth.api_auth_mode()), Ok(None) => LoginStatus::NotAuthenticated, Err(err) => { error!("Failed to read auth.json: {err}"); diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index 91f958a9cf..0a801227f5 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -2,8 +2,8 @@ use crate::exec_command::relativize_to_home; use crate::text_formatting; use chrono::DateTime; use chrono::Local; -use codex_app_server_protocol::AuthMode; use codex_core::AuthManager; +use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::project_doc::discover_project_doc_paths; use codex_protocol::account::PlanType; @@ -90,15 +90,15 @@ pub(crate) fn compose_account_display( ) -> Option { let auth = auth_manager.auth_cached()?; - match auth.mode { - AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => { + match auth { + CodexAuth::ChatGpt(_) | CodexAuth::ChatGptAuthTokens(_) => { let email = auth.get_account_email(); let plan = plan .map(|plan_type| title_case(format!("{plan_type:?}").as_str())) .or_else(|| Some("Unknown".to_string())); Some(StatusAccountDisplay::ChatGpt { email, plan }) } - AuthMode::ApiKey => Some(StatusAccountDisplay::ApiKey), + CodexAuth::ApiKey(_) => Some(StatusAccountDisplay::ApiKey), } } From dfafc546ab127aac45a16b73ae3d859203da66d6 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 30 Jan 2026 10:41:22 -0700 Subject: [PATCH 61/70] chore(feature) Experimental: Personality (#10212) ## Summary Let users start opting in to trying out personalities ## Testing - [x] existing tests pass --- codex-rs/core/src/features.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 961b7a56f6..97fa1022c4 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -560,7 +560,11 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Personality, key: "personality", - stage: Stage::UnderDevelopment, + stage: Stage::Experimental { + name: "Personality", + menu_description: "Choose a communication style for Codex.", + announcement: "NEW: Pick a personality for Codex. Enable in /experimental!", + }, default_enabled: false, }, FeatureSpec { From 23db79fae2925ea74847d2341bd41f7a0e83344a Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 30 Jan 2026 10:41:37 -0700 Subject: [PATCH 62/70] chore(feature) Experimental: Smart Approvals (#10211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Let's start getting feedback on this feature 😅 ## Testing - [x] existing tests pass --- codex-rs/core/src/features.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 97fa1022c4..934ab1c0ba 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -464,7 +464,11 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::RequestRule, key: "request_rule", - stage: Stage::UnderDevelopment, + stage: Stage::Experimental { + name: "Smart approvals", + menu_description: "Get smarter \"Don't ask again\" rule requests.", + announcement: "NEW: Try Smart approvals to get smarter \"Don't ask again\" requests. Enable in /experimental!", + }, default_enabled: false, }, FeatureSpec { From 5662eb8b75cd6aa5e1acbdbf93549f85dee510a1 Mon Sep 17 00:00:00 2001 From: gt-oai Date: Fri, 30 Jan 2026 18:04:09 +0000 Subject: [PATCH 63/70] Load exec policy rules from requirements (#10190) `requirements.toml` should be able to specify rules which always run. My intention here was that these rules could only ever be restrictive, which means the decision can be "prompt" or "forbidden" but never "allow". A requirement of "you must always allow this command" didn't make sense to me, but happy to be gaveled otherwise. Rules already applies the most restrictive decision, so we can safely merge these with rules found in other config folders. --- codex-rs/app-server/src/config_api.rs | 1 + codex-rs/cloud-requirements/src/lib.rs | 1 + codex-rs/core/src/config/constraint.rs | 6 + codex-rs/core/src/config/mod.rs | 1 + .../src/config_loader/config_requirements.rs | 98 +++++++ codex-rs/core/src/config_loader/mod.rs | 1 - .../config_loader/requirements_exec_policy.rs | 82 ++++-- codex-rs/core/src/config_loader/tests.rs | 266 ++++++++++++++---- codex-rs/core/src/exec_policy.rs | 13 +- 9 files changed, 398 insertions(+), 71 deletions(-) diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 5c924d1819..9207668486 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -136,6 +136,7 @@ mod tests { CoreSandboxModeRequirement::ExternalSandbox, ]), mcp_servers: None, + rules: None, }; let mapped = map_requirements_toml_to_api(requirements); diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index ccfa600319..37faed081b 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -317,6 +317,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, mcp_servers: None, + rules: None, }) ); } diff --git a/codex-rs/core/src/config/constraint.rs b/codex-rs/core/src/config/constraint.rs index fa431a6eb2..23b6c57c74 100644 --- a/codex-rs/core/src/config/constraint.rs +++ b/codex-rs/core/src/config/constraint.rs @@ -18,6 +18,12 @@ pub enum ConstraintError { #[error("field `{field_name}` cannot be empty")] EmptyField { field_name: String }, + + #[error("invalid rules in requirements (set by {requirement_source}): {reason}")] + ExecPolicyParse { + requirement_source: RequirementSource, + reason: String, + }, } impl ConstraintError { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fbd8999918..2e1c853620 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1512,6 +1512,7 @@ impl Config { approval_policy: mut constrained_approval_policy, sandbox_policy: mut constrained_sandbox_policy, mcp_servers, + exec_policy: _, } = requirements; constrained_approval_policy diff --git a/codex-rs/core/src/config_loader/config_requirements.rs b/codex-rs/core/src/config_loader/config_requirements.rs index 2c220049cf..6b9184a539 100644 --- a/codex-rs/core/src/config_loader/config_requirements.rs +++ b/codex-rs/core/src/config_loader/config_requirements.rs @@ -6,6 +6,8 @@ use serde::Deserialize; use std::collections::BTreeMap; use std::fmt; +use super::requirements_exec_policy::RequirementsExecPolicy; +use super::requirements_exec_policy::RequirementsExecPolicyToml; use crate::config::Constrained; use crate::config::ConstraintError; @@ -49,6 +51,7 @@ pub struct ConfigRequirements { pub approval_policy: Constrained, pub sandbox_policy: Constrained, pub mcp_servers: Option>>, + pub(crate) exec_policy: Option>, } impl Default for ConfigRequirements { @@ -57,6 +60,7 @@ impl Default for ConfigRequirements { approval_policy: Constrained::allow_any_from_default(), sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly), mcp_servers: None, + exec_policy: None, } } } @@ -79,6 +83,7 @@ pub struct ConfigRequirementsToml { pub allowed_approval_policies: Option>, pub allowed_sandbox_modes: Option>, pub mcp_servers: Option>, + pub rules: Option, } /// Value paired with the requirement source it came from, for better error @@ -108,6 +113,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_approval_policies: Option>>, pub allowed_sandbox_modes: Option>>, pub mcp_servers: Option>>, + pub rules: Option>, } impl ConfigRequirementsWithSources { @@ -139,6 +145,7 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_sandbox_modes, mcp_servers, + rules, } ); } @@ -148,11 +155,13 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_sandbox_modes, mcp_servers, + rules, } = self; ConfigRequirementsToml { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), + rules: rules.map(|sourced| sourced.value), } } } @@ -189,6 +198,7 @@ impl ConfigRequirementsToml { self.allowed_approval_policies.is_none() && self.allowed_sandbox_modes.is_none() && self.mcp_servers.is_none() + && self.rules.is_none() } } @@ -200,6 +210,7 @@ impl TryFrom for ConfigRequirements { allowed_approval_policies, allowed_sandbox_modes, mcp_servers, + rules, } = toml; let approval_policy: Constrained = match allowed_approval_policies { @@ -274,10 +285,24 @@ impl TryFrom for ConfigRequirements { } None => Constrained::allow_any(default_sandbox_policy), }; + let exec_policy = match rules { + Some(Sourced { value, source }) => { + let policy = value.to_requirements_policy().map_err(|err| { + ConstraintError::ExecPolicyParse { + requirement_source: source.clone(), + reason: err.to_string(), + } + })?; + Some(Sourced::new(policy, source)) + } + None => None, + }; + Ok(ConfigRequirements { approval_policy, sandbox_policy, mcp_servers, + exec_policy, }) } } @@ -286,16 +311,24 @@ impl TryFrom for ConfigRequirements { mod tests { use super::*; use anyhow::Result; + use codex_execpolicy::Decision; + use codex_execpolicy::Evaluation; + use codex_execpolicy::RuleMatch; use codex_protocol::protocol::NetworkAccess; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use toml::from_str; + fn tokens(cmd: &[&str]) -> Vec { + cmd.iter().map(std::string::ToString::to_string).collect() + } + fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { allowed_approval_policies, allowed_sandbox_modes, mcp_servers, + rules, } = toml; ConfigRequirementsWithSources { allowed_approval_policies: allowed_approval_policies @@ -303,6 +336,7 @@ mod tests { allowed_sandbox_modes: allowed_sandbox_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)), + rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)), } } @@ -323,6 +357,7 @@ mod tests { allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), mcp_servers: None, + rules: None, }; target.merge_unset_fields(source.clone(), other); @@ -336,6 +371,7 @@ mod tests { )), allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source)), mcp_servers: None, + rules: None, } ); } @@ -364,6 +400,7 @@ mod tests { )), allowed_sandbox_modes: None, mcp_servers: None, + rules: None, } ); Ok(()) @@ -400,6 +437,7 @@ mod tests { )), allowed_sandbox_modes: None, mcp_servers: None, + rules: None, } ); Ok(()) @@ -626,4 +664,64 @@ mod tests { ); Ok(()) } + + #[test] + fn deserialize_exec_policy_requirements() -> Result<()> { + let toml_str = r#" + [rules] + prefix_rules = [ + { pattern = [{ token = "rm" }], decision = "forbidden" }, + ] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + let policy = requirements.exec_policy.expect("exec policy").value; + + assert_eq!( + policy.as_ref().check(&tokens(&["rm", "-rf"]), &|_| { + panic!("rule should match so heuristic should not be called"); + }), + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["rm"]), + decision: Decision::Forbidden, + justification: None, + }], + } + ); + + Ok(()) + } + + #[test] + fn exec_policy_error_includes_requirement_source() -> Result<()> { + let toml_str = r#" + [rules] + prefix_rules = [ + { pattern = [{ token = "rm" }] }, + ] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements_toml_file = + AbsolutePathBuf::from_absolute_path("/etc/codex/requirements.toml")?; + let source_location = RequirementSource::SystemRequirementsToml { + file: requirements_toml_file, + }; + + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source_location.clone(), config); + let err = ConfigRequirements::try_from(requirements_with_sources) + .expect_err("invalid exec policy"); + + assert_eq!( + err, + ConstraintError::ExecPolicyParse { + requirement_source: source_location, + reason: "rules prefix_rule at index 0 is missing a decision".to_string(), + } + ); + + Ok(()) + } } diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 0dd704cab5..eba89fdb3d 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -7,7 +7,6 @@ mod layer_io; mod macos; mod merge; mod overrides; -#[cfg(test)] mod requirements_exec_policy; mod state; diff --git a/codex-rs/core/src/config_loader/requirements_exec_policy.rs b/codex-rs/core/src/config_loader/requirements_exec_policy.rs index cbc1d7531e..74546fc426 100644 --- a/codex-rs/core/src/config_loader/requirements_exec_policy.rs +++ b/codex-rs/core/src/config_loader/requirements_exec_policy.rs @@ -9,16 +9,43 @@ use serde::Deserialize; use std::sync::Arc; use thiserror::Error; -/// TOML types for expressing exec policy requirements. -/// -/// These types are kept separate from `ConfigRequirementsToml` and are -/// converted into `codex-execpolicy` rules. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct RequirementsExecPolicyTomlRoot { - pub exec_policy: RequirementsExecPolicyToml, +#[derive(Debug, Clone)] +pub(crate) struct RequirementsExecPolicy { + policy: Policy, } -/// TOML representation of `[exec_policy]` within `requirements.toml`. +impl RequirementsExecPolicy { + pub fn new(policy: Policy) -> Self { + Self { policy } + } +} + +impl PartialEq for RequirementsExecPolicy { + fn eq(&self, other: &Self) -> bool { + policy_fingerprint(&self.policy) == policy_fingerprint(&other.policy) + } +} + +impl Eq for RequirementsExecPolicy {} + +impl AsRef for RequirementsExecPolicy { + fn as_ref(&self) -> &Policy { + &self.policy + } +} + +fn policy_fingerprint(policy: &Policy) -> Vec { + let mut entries = Vec::new(); + for (program, rules) in policy.rules().iter_all() { + for rule in rules { + entries.push(format!("{program}:{rule:?}")); + } + } + entries.sort(); + entries +} + +/// TOML representation of `[rules]` within `requirements.toml`. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct RequirementsExecPolicyToml { pub prefix_rules: Vec, @@ -65,14 +92,14 @@ impl RequirementsExecPolicyDecisionToml { #[derive(Debug, Error)] pub enum RequirementsExecPolicyParseError { - #[error("exec policy prefix_rules cannot be empty")] + #[error("rules prefix_rules cannot be empty")] EmptyPrefixRules, - #[error("exec policy prefix_rule at index {rule_index} has an empty pattern")] + #[error("rules prefix_rule at index {rule_index} has an empty pattern")] EmptyPattern { rule_index: usize }, #[error( - "exec policy prefix_rule at index {rule_index} has an invalid pattern token at index {token_index}: {reason}" + "rules prefix_rule at index {rule_index} has an invalid pattern token at index {token_index}: {reason}" )] InvalidPatternToken { rule_index: usize, @@ -80,12 +107,20 @@ pub enum RequirementsExecPolicyParseError { reason: String, }, - #[error("exec policy prefix_rule at index {rule_index} has an empty justification")] + #[error("rules prefix_rule at index {rule_index} has an empty justification")] EmptyJustification { rule_index: usize }, + + #[error("rules prefix_rule at index {rule_index} is missing a decision")] + MissingDecision { rule_index: usize }, + + #[error( + "rules prefix_rule at index {rule_index} has decision 'allow', which is not permitted in requirements.toml: Codex merges these rules with other config and uses the most restrictive result (use 'prompt' or 'forbidden')" + )] + AllowDecisionNotAllowed { rule_index: usize }, } impl RequirementsExecPolicyToml { - /// Convert requirements TOML exec policy rules into the internal `.rules` + /// Convert requirements TOML rules into the internal `.rules` /// representation used by `codex-execpolicy`. pub fn to_policy(&self) -> Result { if self.prefix_rules.is_empty() { @@ -112,10 +147,17 @@ impl RequirementsExecPolicyToml { .map(|(token_index, token)| parse_pattern_token(token, rule_index, token_index)) .collect::, _>>()?; - let decision = rule - .decision - .map(RequirementsExecPolicyDecisionToml::as_decision) - .unwrap_or(Decision::Allow); + let decision = match rule.decision { + Some(RequirementsExecPolicyDecisionToml::Allow) => { + return Err(RequirementsExecPolicyParseError::AllowDecisionNotAllowed { + rule_index, + }); + } + Some(decision) => decision.as_decision(), + None => { + return Err(RequirementsExecPolicyParseError::MissingDecision { rule_index }); + } + }; let justification = rule.justification.clone(); let (first_token, remaining_tokens) = pattern_tokens @@ -139,6 +181,12 @@ impl RequirementsExecPolicyToml { Ok(Policy::new(rules_by_program)) } + + pub(crate) fn to_requirements_policy( + &self, + ) -> Result { + self.to_policy().map(RequirementsExecPolicy::new) + } } fn parse_pattern_token( diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index c213c0b2ea..38d104fc19 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -502,6 +502,7 @@ allowed_approval_policies = ["on-request"] allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, mcp_servers: None, + rules: None, }, ); load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?; @@ -535,6 +536,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, mcp_servers: None, + rules: None, }; let expected = requirements.clone(); let cloud_requirements = CloudRequirementsLoader::new(async move { Some(requirements) }); @@ -1016,49 +1018,79 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() } mod requirements_exec_policy_tests { + use super::super::config_requirements::ConfigRequirementsWithSources; use super::super::requirements_exec_policy::RequirementsExecPolicyDecisionToml; + use super::super::requirements_exec_policy::RequirementsExecPolicyParseError; use super::super::requirements_exec_policy::RequirementsExecPolicyPatternTokenToml; use super::super::requirements_exec_policy::RequirementsExecPolicyPrefixRuleToml; use super::super::requirements_exec_policy::RequirementsExecPolicyToml; - use super::super::requirements_exec_policy::RequirementsExecPolicyTomlRoot; + use crate::config_loader::ConfigLayerEntry; + use crate::config_loader::ConfigLayerStack; + use crate::config_loader::ConfigRequirements; + use crate::config_loader::ConfigRequirementsToml; + use crate::config_loader::RequirementSource; + use crate::exec_policy::load_exec_policy; + use codex_app_server_protocol::ConfigLayerSource; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::RuleMatch; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; + use std::path::Path; + use tempfile::tempdir; + use toml::Value as TomlValue; use toml::from_str; fn tokens(cmd: &[&str]) -> Vec { cmd.iter().map(std::string::ToString::to_string).collect() } - fn allow_all(_: &[String]) -> Decision { - Decision::Allow + fn panic_if_called(_: &[String]) -> Decision { + panic!("rule should match so heuristic should not be called"); + } + + fn config_stack_for_dot_codex_folder_with_requirements( + dot_codex_folder: &Path, + requirements: ConfigRequirements, + ) -> ConfigLayerStack { + let dot_codex_folder = AbsolutePathBuf::from_absolute_path(dot_codex_folder) + .expect("absolute dot_codex_folder"); + let layer = ConfigLayerEntry::new( + ConfigLayerSource::Project { dot_codex_folder }, + TomlValue::Table(Default::default()), + ); + ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default()) + .expect("ConfigLayerStack") + } + + fn requirements_from_toml(toml_str: &str) -> ConfigRequirements { + let config: ConfigRequirementsToml = from_str(toml_str).expect("parse requirements toml"); + let mut with_sources = ConfigRequirementsWithSources::default(); + with_sources.merge_unset_fields(RequirementSource::Unknown, config); + ConfigRequirements::try_from(with_sources).expect("requirements") } #[test] fn parses_single_prefix_rule_from_raw_toml() -> anyhow::Result<()> { let toml_str = r#" -[exec_policy] prefix_rules = [ { pattern = [{ token = "rm" }], decision = "forbidden" }, ] "#; - let parsed: RequirementsExecPolicyTomlRoot = from_str(toml_str)?; + let parsed: RequirementsExecPolicyToml = from_str(toml_str)?; assert_eq!( parsed, - RequirementsExecPolicyTomlRoot { - exec_policy: RequirementsExecPolicyToml { - prefix_rules: vec![RequirementsExecPolicyPrefixRuleToml { - pattern: vec![RequirementsExecPolicyPatternTokenToml { - token: Some("rm".to_string()), - any_of: None, - }], - decision: Some(RequirementsExecPolicyDecisionToml::Forbidden), - justification: None, + RequirementsExecPolicyToml { + prefix_rules: vec![RequirementsExecPolicyPrefixRuleToml { + pattern: vec![RequirementsExecPolicyPatternTokenToml { + token: Some("rm".to_string()), + any_of: None, }], - }, + decision: Some(RequirementsExecPolicyDecisionToml::Forbidden), + justification: None, + }], } ); @@ -1068,44 +1100,41 @@ prefix_rules = [ #[test] fn parses_multiple_prefix_rules_from_raw_toml() -> anyhow::Result<()> { let toml_str = r#" -[exec_policy] prefix_rules = [ { pattern = [{ token = "rm" }], decision = "forbidden" }, { pattern = [{ token = "git" }, { any_of = ["push", "commit"] }], decision = "prompt", justification = "review changes before push or commit" }, ] "#; - let parsed: RequirementsExecPolicyTomlRoot = from_str(toml_str)?; + let parsed: RequirementsExecPolicyToml = from_str(toml_str)?; assert_eq!( parsed, - RequirementsExecPolicyTomlRoot { - exec_policy: RequirementsExecPolicyToml { - prefix_rules: vec![ - RequirementsExecPolicyPrefixRuleToml { - pattern: vec![RequirementsExecPolicyPatternTokenToml { - token: Some("rm".to_string()), + RequirementsExecPolicyToml { + prefix_rules: vec![ + RequirementsExecPolicyPrefixRuleToml { + pattern: vec![RequirementsExecPolicyPatternTokenToml { + token: Some("rm".to_string()), + any_of: None, + }], + decision: Some(RequirementsExecPolicyDecisionToml::Forbidden), + justification: None, + }, + RequirementsExecPolicyPrefixRuleToml { + pattern: vec![ + RequirementsExecPolicyPatternTokenToml { + token: Some("git".to_string()), any_of: None, - }], - decision: Some(RequirementsExecPolicyDecisionToml::Forbidden), - justification: None, - }, - RequirementsExecPolicyPrefixRuleToml { - pattern: vec![ - RequirementsExecPolicyPatternTokenToml { - token: Some("git".to_string()), - any_of: None, - }, - RequirementsExecPolicyPatternTokenToml { - token: None, - any_of: Some(vec!["push".to_string(), "commit".to_string()]), - }, - ], - decision: Some(RequirementsExecPolicyDecisionToml::Prompt), - justification: Some("review changes before push or commit".to_string()), - }, - ], - }, + }, + RequirementsExecPolicyPatternTokenToml { + token: None, + any_of: Some(vec!["push".to_string(), "commit".to_string()]), + }, + ], + decision: Some(RequirementsExecPolicyDecisionToml::Prompt), + justification: Some("review changes before push or commit".to_string()), + }, + ], } ); @@ -1115,17 +1144,16 @@ prefix_rules = [ #[test] fn converts_rules_toml_into_internal_policy_representation() -> anyhow::Result<()> { let toml_str = r#" -[exec_policy] prefix_rules = [ { pattern = [{ token = "rm" }], decision = "forbidden" }, ] "#; - let parsed: RequirementsExecPolicyTomlRoot = from_str(toml_str)?; - let policy = parsed.exec_policy.to_policy()?; + let parsed: RequirementsExecPolicyToml = from_str(toml_str)?; + let policy = parsed.to_policy()?; assert_eq!( - policy.check(&tokens(&["rm", "-rf", "/tmp"]), &allow_all), + policy.check(&tokens(&["rm", "-rf", "/tmp"]), &panic_if_called), Evaluation { decision: Decision::Forbidden, matched_rules: vec![RuleMatch::PrefixRuleMatch { @@ -1142,16 +1170,15 @@ prefix_rules = [ #[test] fn head_any_of_expands_into_multiple_program_rules() -> anyhow::Result<()> { let toml_str = r#" -[exec_policy] prefix_rules = [ { pattern = [{ any_of = ["git", "hg"] }, { token = "status" }], decision = "prompt" }, ] "#; - let parsed: RequirementsExecPolicyTomlRoot = from_str(toml_str)?; - let policy = parsed.exec_policy.to_policy()?; + let parsed: RequirementsExecPolicyToml = from_str(toml_str)?; + let policy = parsed.to_policy()?; assert_eq!( - policy.check(&tokens(&["git", "status"]), &allow_all), + policy.check(&tokens(&["git", "status"]), &panic_if_called), Evaluation { decision: Decision::Prompt, matched_rules: vec![RuleMatch::PrefixRuleMatch { @@ -1162,7 +1189,7 @@ prefix_rules = [ } ); assert_eq!( - policy.check(&tokens(&["hg", "status"]), &allow_all), + policy.check(&tokens(&["hg", "status"]), &panic_if_called), Evaluation { decision: Decision::Prompt, matched_rules: vec![RuleMatch::PrefixRuleMatch { @@ -1175,4 +1202,139 @@ prefix_rules = [ Ok(()) } + + #[test] + fn missing_decision_is_rejected() -> anyhow::Result<()> { + let toml_str = r#" +prefix_rules = [ + { pattern = [{ token = "rm" }] }, +] +"#; + + let parsed: RequirementsExecPolicyToml = from_str(toml_str)?; + let err = parsed.to_policy().expect_err("missing decision"); + + assert!(matches!( + err, + RequirementsExecPolicyParseError::MissingDecision { rule_index: 0 } + )); + Ok(()) + } + + #[test] + fn allow_decision_is_rejected() -> anyhow::Result<()> { + let toml_str = r#" +prefix_rules = [ + { pattern = [{ token = "rm" }], decision = "allow" }, +] +"#; + + let parsed: RequirementsExecPolicyToml = from_str(toml_str)?; + let err = parsed.to_policy().expect_err("allow decision not allowed"); + + assert!(matches!( + err, + RequirementsExecPolicyParseError::AllowDecisionNotAllowed { rule_index: 0 } + )); + Ok(()) + } + + #[test] + fn empty_prefix_rules_is_rejected() -> anyhow::Result<()> { + let toml_str = r#" +prefix_rules = [] +"#; + + let parsed: RequirementsExecPolicyToml = from_str(toml_str)?; + let err = parsed.to_policy().expect_err("empty prefix rules"); + + assert!(matches!( + err, + RequirementsExecPolicyParseError::EmptyPrefixRules + )); + Ok(()) + } + + #[tokio::test] + async fn loads_requirements_exec_policy_without_rules_files() -> anyhow::Result<()> { + let temp_dir = tempdir()?; + let requirements = requirements_from_toml( + r#" + [rules] + prefix_rules = [ + { pattern = [{ token = "rm" }], decision = "forbidden" }, + ] + "#, + ); + let config_stack = + config_stack_for_dot_codex_folder_with_requirements(temp_dir.path(), requirements); + + let policy = load_exec_policy(&config_stack).await?; + + assert_eq!( + policy.check_multiple([vec!["rm".to_string()]].iter(), &panic_if_called), + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["rm".to_string()], + decision: Decision::Forbidden, + justification: None, + }], + } + ); + + Ok(()) + } + + #[tokio::test] + async fn merges_requirements_exec_policy_with_file_rules() -> anyhow::Result<()> { + let temp_dir = tempdir()?; + let policy_dir = temp_dir.path().join("rules"); + std::fs::create_dir_all(&policy_dir)?; + std::fs::write( + policy_dir.join("deny.rules"), + r#"prefix_rule(pattern=["rm"], decision="forbidden")"#, + )?; + + let requirements = requirements_from_toml( + r#" + [rules] + prefix_rules = [ + { pattern = [{ token = "git" }, { token = "push" }], decision = "prompt" }, + ] + "#, + ); + let config_stack = + config_stack_for_dot_codex_folder_with_requirements(temp_dir.path(), requirements); + + let policy = load_exec_policy(&config_stack).await?; + + assert_eq!( + policy.check_multiple([vec!["rm".to_string()]].iter(), &panic_if_called), + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["rm".to_string()], + decision: Decision::Forbidden, + justification: None, + }], + } + ); + assert_eq!( + policy.check_multiple( + [vec!["git".to_string(), "push".to_string()]].iter(), + &panic_if_called + ), + Evaluation { + decision: Decision::Prompt, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["git".to_string(), "push".to_string()], + decision: Decision::Prompt, + justification: None, + }], + } + ); + + Ok(()) + } } diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 07a9ebceba..c0cb7f8fcf 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -280,7 +280,18 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result Date: Fri, 30 Jan 2026 10:14:46 -0800 Subject: [PATCH 64/70] plan mode: add TL;DR checkpoint and client behavior note (#10195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Tightens Plan Mode to encourage exploration-first behavior and more back-and-forth alignment. - Adds a required TL;DR checkpoint before drafting the full plan. - Clarifies client behavior that can cause premature “Implement this plan?” prompts. ## What changed - Require at least one targeted non-mutating exploration pass before the first user question. - Insert a TL;DR checkpoint between Phase 2 (intent) and Phase 3 (implementation). - TL;DR checkpoint guidance: - Label: “Proposed Plan (TL;DR)” - Format: 3–5 bullets using `- ` - Options: exactly one option, “Approve” - `isOther: true`, with explicit guidance that “None of the above” is the edit path in the current UI. - Require the final plan to include a TL;DR consistent with the approved checkpoint. ## Why - In Plan Mode, any normal assistant message at turn completion is treated as plan content by the client. This can trigger premature “Implement this plan?” prompts. - The TL;DR checkpoint aligns on direction before Codex drafts a long, decision-complete plan. ## Testing - Manual: built the local CLI and verified the flow now explores first, presents a TL;DR checkpoint, and only drafts the full plan after approval. --------- Co-authored-by: Nick Baumann <@openai.com> --- codex-rs/core/templates/collaboration_mode/plan.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 4b5f450a50..cffbf16d36 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -37,6 +37,8 @@ When in doubt: if the action would reasonably be described as "doing the work" r Begin by grounding yourself in the actual environment. Eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Silent exploration between turns is allowed and encouraged. +Before asking the user any question, perform at least one targeted non-mutating exploration pass (for example: search relevant files, inspect likely entrypoints/configs, confirm current implementation shape), unless no local environment/repo is available. + Do not ask questions that can be answered from the repo or system (for example, "where is this struct?" or "which UI component should we use?" when exploration can make it clear). Only ask once you have exhausted reasonable non-mutating exploration. ## PHASE 2 — Intent chat (what they actually want) @@ -97,6 +99,7 @@ Only output the final plan when it is decision complete and leaves no decisions The final plan must be plan-only and include: * A clear title +* A TL;DR section (3–5 bullets) * Exact file paths to change * Exact structures or shapes to introduce or modify * Exact function, method, type, and variable names and signatures From 40bf11bd52422c3a5bf39fe8daae8999949874be Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 30 Jan 2026 10:29:54 -0800 Subject: [PATCH 65/70] chore: fix the build breakage that came from a merge race (#10239) I think I needed to rebase on top of https://github.com/openai/codex/pull/10167 before merging https://github.com/openai/codex/pull/10208. --- codex-rs/Cargo.lock | 1 - codex-rs/cloud-requirements/Cargo.toml | 1 - codex-rs/cloud-requirements/src/lib.rs | 5 +---- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 44f35f78ae..5cb338be1d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1298,7 +1298,6 @@ version = "0.0.0" dependencies = [ "async-trait", "base64", - "codex-app-server-protocol", "codex-backend-client", "codex-core", "codex-otel", diff --git a/codex-rs/cloud-requirements/Cargo.toml b/codex-rs/cloud-requirements/Cargo.toml index 2bfde660bf..071c98b9b4 100644 --- a/codex-rs/cloud-requirements/Cargo.toml +++ b/codex-rs/cloud-requirements/Cargo.toml @@ -9,7 +9,6 @@ workspace = true [dependencies] async-trait = { workspace = true } -codex-app-server-protocol = { workspace = true } codex-backend-client = { workspace = true } codex-core = { workspace = true } codex-otel = { workspace = true } diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 37faed081b..ed1bb051d5 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -8,7 +8,6 @@ //! requirements before Codex will run. use async_trait::async_trait; -use codex_app_server_protocol::AuthMode; use codex_backend_client::Client as BackendClient; use codex_core::AuthManager; use codex_core::auth::CodexAuth; @@ -120,9 +119,7 @@ impl CloudRequirementsService { async fn fetch(&self) -> Option { let auth = self.auth_manager.auth().await?; - if !(auth.mode == AuthMode::ChatGPT - && auth.account_plan_type() == Some(PlanType::Enterprise)) - { + if !(auth.is_chatgpt_auth() && auth.account_plan_type() == Some(PlanType::Enterprise)) { return None; } From ec4a2d07e411d11f4de13b4e09aa7386c914df6d Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Fri, 30 Jan 2026 10:59:30 -0800 Subject: [PATCH 66/70] Plan mode: stream proposed plans, emit plan items, and render in TUI (#9786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Stream proposed plans in Plan Mode using `` tags parsed in core, emitting plan deltas plus a plan `ThreadItem`, while stripping tags from normal assistant output. - Persist plan items and rebuild them on resume so proposed plans show in thread history. - Wire plan items/deltas through app-server protocol v2 and render a dedicated proposed-plan view in the TUI, including the “Implement this plan?” prompt only when a plan item is present. ## Changes ### Core (`codex-rs/core`) - Added a generic, line-based tag parser that buffers each line until it can disprove a tag prefix; implements auto-close on `finish()` for unterminated tags. `codex-rs/core/src/tagged_block_parser.rs` - Refactored proposed plan parsing to wrap the generic parser. `codex-rs/core/src/proposed_plan_parser.rs` - In plan mode, stream assistant deltas as: - **Normal text** → `AgentMessageContentDelta` - **Plan text** → `PlanDelta` + `TurnItem::Plan` start/completion (`codex-rs/core/src/codex.rs`) - Final plan item content is derived from the completed assistant message (authoritative), not necessarily the concatenated deltas. - Strips `` blocks from assistant text in plan mode so tags don’t appear in normal messages. (`codex-rs/core/src/stream_events_utils.rs`) - Persist `ItemCompleted` events only for plan items for rollout replay. (`codex-rs/core/src/rollout/policy.rs`) - Guard `update_plan` tool in Plan Mode with a clear error message. (`codex-rs/core/src/tools/handlers/plan.rs`) - Updated Plan Mode prompt to: - keep `` out of non-final reasoning/preambles - require exact tag formatting - allow only one `` block per turn (`codex-rs/core/templates/collaboration_mode/plan.md`) ### Protocol / App-server protocol - Added `TurnItem::Plan` and `PlanDeltaEvent` to core protocol items. (`codex-rs/protocol/src/items.rs`, `codex-rs/protocol/src/protocol.rs`) - Added v2 `ThreadItem::Plan` and `PlanDeltaNotification` with EXPERIMENTAL markers and note that deltas may not match the final plan item. (`codex-rs/app-server-protocol/src/protocol/v2.rs`) - Added plan delta route in app-server protocol common mapping. (`codex-rs/app-server-protocol/src/protocol/common.rs`) - Rebuild plan items from persisted `ItemCompleted` events on resume. (`codex-rs/app-server-protocol/src/protocol/thread_history.rs`) ### App-server - Forward plan deltas to v2 clients and map core plan items to v2 plan items. (`codex-rs/app-server/src/bespoke_event_handling.rs`, `codex-rs/app-server/src/codex_message_processor.rs`) - Added v2 plan item tests. (`codex-rs/app-server/tests/suite/v2/plan_item.rs`) ### TUI - Added a dedicated proposed plan history cell with special background and padding, and moved “• Proposed Plan” outside the highlighted block. (`codex-rs/tui/src/history_cell.rs`, `codex-rs/tui/src/style.rs`) - Only show “Implement this plan?” when a plan item exists. (`codex-rs/tui/src/chatwidget.rs`, `codex-rs/tui/src/chatwidget/tests.rs`) Screenshot 2026-01-29 at 7 06 24 PM ### Docs / Misc - Updated protocol docs to mention plan deltas. (`codex-rs/docs/protocol_v1.md`) - Minor plumbing updates in exec/debug clients to tolerate plan deltas. (`codex-rs/debug-client/src/reader.rs`, `codex-rs/exec/...`) ## Tests - Added core integration tests: - Plan mode strips plan from agent messages. - Missing `` closes at end-of-message. (`codex-rs/core/tests/suite/items.rs`) - Added unit tests for generic tag parser (prefix buffering, non-tag lines, auto-close). (`codex-rs/core/src/tagged_block_parser.rs`) - Existing app-server plan item tests in v2. (`codex-rs/app-server/tests/suite/v2/plan_item.rs`) ## Notes / Behavior - Plan output no longer appears in standard assistant text in Plan Mode; it streams via `PlanDelta` and completes as a `TurnItem::Plan`. - The final plan item content is authoritative and may diverge from streamed deltas (documented as experimental). - Reasoning summaries are not filtered; prompt instructs the model not to include `` outside the final plan message. ## Codex Author `codex fork 019bec2d-b09d-7450-b292-d7bcdddcdbfb` --- .../src/protocol/common.rs | 2 + .../src/protocol/thread_history.rs | 15 + .../app-server-protocol/src/protocol/v2.rs | 21 + codex-rs/app-server/README.md | 5 + .../app-server/src/bespoke_event_handling.rs | 18 +- .../app-server/tests/common/mcp_process.rs | 4 + codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../app-server/tests/suite/v2/plan_item.rs | 257 ++++++++++ codex-rs/core/src/agent/control.rs | 2 + codex-rs/core/src/codex.rs | 461 +++++++++++++++++- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/lib.rs | 2 + codex-rs/core/src/proposed_plan_parser.rs | 185 +++++++ codex-rs/core/src/rollout/policy.rs | 8 +- codex-rs/core/src/stream_events_utils.rs | 54 +- codex-rs/core/src/tagged_block_parser.rs | 314 ++++++++++++ codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/tools/handlers/plan.rs | 6 + .../core/templates/collaboration_mode/plan.md | 26 +- codex-rs/core/tests/suite/items.rs | 267 ++++++++++ codex-rs/debug-client/src/reader.rs | 5 + codex-rs/docs/protocol_v1.md | 5 +- .../src/event_processor_with_human_output.rs | 18 +- .../src/event_processor_with_jsonl_output.rs | 14 +- .../tests/event_processor_with_json_output.rs | 2 + codex-rs/mcp-server/src/codex_tool_runner.rs | 3 + codex-rs/protocol/src/config_types.rs | 5 +- codex-rs/protocol/src/items.rs | 9 + codex-rs/protocol/src/plan_tool.rs | 1 + codex-rs/protocol/src/protocol.rs | 12 + codex-rs/tui/src/chatwidget.rs | 114 ++++- codex-rs/tui/src/chatwidget/tests.rs | 42 +- codex-rs/tui/src/history_cell.rs | 58 +++ codex-rs/tui/src/streaming/controller.rs | 103 ++++ codex-rs/tui/src/style.rs | 21 + 36 files changed, 2021 insertions(+), 42 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/plan_item.rs create mode 100644 codex-rs/core/src/proposed_plan_parser.rs create mode 100644 codex-rs/core/src/tagged_block_parser.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 67736374ec..efcc651b33 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -609,6 +609,8 @@ server_notification_definitions! { /// This event is internal-only. Used by Codex Cloud. RawResponseItemCompleted => "rawResponseItem/completed" (v2::RawResponseItemCompletedNotification), AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), + /// EXPERIMENTAL - proposed plan streaming deltas for plan items. + PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification), CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification), FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification), 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 e6c679b418..39efe47655 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -6,6 +6,7 @@ use crate::protocol::v2::UserInput; use codex_protocol::protocol::AgentReasoningEvent; use codex_protocol::protocol::AgentReasoningRawContentEvent; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::UserMessageEvent; @@ -55,6 +56,7 @@ impl ThreadHistoryBuilder { EventMsg::AgentReasoningRawContent(payload) => { self.handle_agent_reasoning_raw_content(payload) } + EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload), EventMsg::TokenCount(_) => {} EventMsg::EnteredReviewMode(_) => {} EventMsg::ExitedReviewMode(_) => {} @@ -125,6 +127,19 @@ impl ThreadHistoryBuilder { }); } + fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) { + if let codex_protocol::items::TurnItem::Plan(plan) = &payload.item { + if plan.text.is_empty() { + return; + } + let id = self.next_item_id(); + self.ensure_turn().items.push(ThreadItem::Plan { + id, + text: plan.text.clone(), + }); + } + } + fn handle_turn_aborted(&mut self, _payload: &TurnAbortedEvent) { let Some(turn) = self.current_turn.as_mut() else { return; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 8bea094487..8164eb5289 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2036,6 +2036,11 @@ pub enum ThreadItem { AgentMessage { id: String, text: String }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] + /// EXPERIMENTAL - proposed plan item content. The completed plan item is + /// authoritative and may not match the concatenation of `PlanDelta` text. + Plan { id: String, text: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] Reasoning { id: String, #[serde(default)] @@ -2140,6 +2145,10 @@ impl From for ThreadItem { .collect::(); ThreadItem::AgentMessage { id: agent.id, text } } + CoreTurnItem::Plan(plan) => ThreadItem::Plan { + id: plan.id, + text: plan.text, + }, CoreTurnItem::Reasoning(reasoning) => ThreadItem::Reasoning { id: reasoning.id, summary: reasoning.summary_text, @@ -2428,6 +2437,18 @@ pub struct AgentMessageDeltaNotification { pub delta: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should +/// not assume concatenated deltas match the completed plan item content. +pub struct PlanDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3798d76027..76d1131a2e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -444,6 +444,7 @@ Today both notifications carry an empty `items` array even when item events were - `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`). - `agentMessage` — `{id, text}` containing the accumulated agent reply. +- `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental). - `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models). - `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`. - `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`. @@ -467,6 +468,10 @@ There are additional item-specific events: - `item/agentMessage/delta` — appends streamed text for the agent message; concatenate `delta` values for the same `itemId` in order to reconstruct the full reply. +#### plan + +- `item/plan/delta` — streams proposed plan content for plan items (experimental); concatenate `delta` values for the same plan `itemId`. These deltas correspond to the `` block. + #### reasoning - `item/reasoning/summaryTextDelta` — streams readable reasoning summaries; `summaryIndex` increments when a new summary section opens. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index dd2628deb7..bf8fad3a9c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -44,6 +44,7 @@ use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind; +use codex_app_server_protocol::PlanDeltaNotification; use codex_app_server_protocol::RawResponseItemCompletedNotification; use codex_app_server_protocol::ReasoningSummaryPartAddedNotification; use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; @@ -118,6 +119,7 @@ pub(crate) async fn apply_bespoke_event_handling( msg, } = event; match msg { + EventMsg::TurnStarted(_) => {} EventMsg::TurnComplete(_ev) => { handle_turn_complete( conversation_id, @@ -593,14 +595,27 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::AgentMessageContentDelta(event) => { + let codex_protocol::protocol::AgentMessageContentDeltaEvent { item_id, delta, .. } = + event; let notification = AgentMessageDeltaNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + item_id, + delta, + }; + outgoing + .send_server_notification(ServerNotification::AgentMessageDelta(notification)) + .await; + } + EventMsg::PlanDelta(event) => { + let notification = PlanDeltaNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), item_id: event.item_id, delta: event.delta, }; outgoing - .send_server_notification(ServerNotification::AgentMessageDelta(notification)) + .send_server_notification(ServerNotification::PlanDelta(notification)) .await; } EventMsg::ContextCompacted(..) => { @@ -1160,6 +1175,7 @@ async fn handle_turn_plan_update( api_version: ApiVersion, outgoing: &OutgoingMessageSender, ) { + // `update_plan` is a todo/checklist tool; it is not related to plan-mode updates if let ApiVersion::V2 = api_version { let notification = TurnPlanUpdatedNotification { thread_id: conversation_id.to_string(), diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 4eeab8f950..90cd7635a3 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -736,6 +736,10 @@ impl McpProcess { Ok(notification) } + pub async fn read_next_message(&mut self) -> anyhow::Result { + self.read_stream_until_message(|_| true).await + } + /// Clears any buffered messages so future reads only consider new stream items. /// /// We call this when e.g. we want to validate against the next turn and no longer care about diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 8f54753bf6..b67c3e5c33 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -8,6 +8,7 @@ mod dynamic_tools; mod initialize; mod model_list; mod output_schema; +mod plan_item; mod rate_limits; mod request_user_input; mod review; diff --git a/codex-rs/app-server/tests/suite/v2/plan_item.rs b/codex-rs/app-server/tests/suite/v2/plan_item.rs new file mode 100644 index 0000000000..d138954ac4 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/plan_item.rs @@ -0,0 +1,257 @@ +use anyhow::Result; +use anyhow::anyhow; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::to_response; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PlanDeltaNotification; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::features::FEATURES; +use codex_core::features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { + skip_if_no_network!(Ok(())); + + let plan_block = "\n# Final plan\n- first\n- second\n\n"; + let full_message = format!("Preface\n{plan_block}Postscript"); + let responses = vec![responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_message_item_added("msg-1", ""), + responses::ev_output_text_delta(&full_message), + responses::ev_assistant_message("msg-1", &full_message), + responses::ev_completed("resp-1"), + ])]; + let server = create_mock_responses_server_sequence(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let turn = start_plan_mode_turn(&mut mcp).await?; + let (_, completed_items, plan_deltas, turn_completed) = + collect_turn_notifications(&mut mcp).await?; + + assert_eq!(turn_completed.turn.id, turn.id); + assert_eq!(turn_completed.turn.status, TurnStatus::Completed); + + let expected_plan = ThreadItem::Plan { + id: format!("{}-plan", turn.id), + text: "# Final plan\n- first\n- second\n".to_string(), + }; + let expected_plan_id = format!("{}-plan", turn.id); + let streamed_plan = plan_deltas + .iter() + .map(|delta| delta.delta.as_str()) + .collect::(); + assert_eq!(streamed_plan, "# Final plan\n- first\n- second\n"); + assert!( + plan_deltas + .iter() + .all(|delta| delta.item_id == expected_plan_id) + ); + let plan_items = completed_items + .iter() + .filter_map(|item| match item { + ThreadItem::Plan { .. } => Some(item.clone()), + _ => None, + }) + .collect::>(); + assert_eq!(plan_items, vec![expected_plan]); + assert!( + completed_items + .iter() + .any(|item| matches!(item, ThreadItem::AgentMessage { .. })), + "agent message items should still be emitted alongside the plan item" + ); + + Ok(()) +} + +#[tokio::test] +async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()> { + skip_if_no_network!(Ok(())); + + let responses = vec![responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ])]; + let server = create_mock_responses_server_sequence(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let _turn = start_plan_mode_turn(&mut mcp).await?; + let (_, completed_items, plan_deltas, _) = collect_turn_notifications(&mut mcp).await?; + + let has_plan_item = completed_items + .iter() + .any(|item| matches!(item, ThreadItem::Plan { .. })); + assert!(!has_plan_item); + assert!(plan_deltas.is_empty()); + + Ok(()) +} + +async fn start_plan_mode_turn(mcp: &mut McpProcess) -> Result { + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let thread = to_response::(thread_resp)?.thread; + + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "mock-model".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }; + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Plan this".to_string(), + text_elements: Vec::new(), + }], + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + Ok(to_response::(turn_resp)?.turn) +} + +async fn collect_turn_notifications( + mcp: &mut McpProcess, +) -> Result<( + Vec, + Vec, + Vec, + TurnCompletedNotification, +)> { + let mut started_items = Vec::new(); + let mut completed_items = Vec::new(); + let mut plan_deltas = Vec::new(); + + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "item/started" => { + let params = notification + .params + .ok_or_else(|| anyhow!("item/started notifications must include params"))?; + let payload: ItemStartedNotification = serde_json::from_value(params)?; + started_items.push(payload.item); + } + "item/completed" => { + let params = notification + .params + .ok_or_else(|| anyhow!("item/completed notifications must include params"))?; + let payload: ItemCompletedNotification = serde_json::from_value(params)?; + completed_items.push(payload.item); + } + "item/plan/delta" => { + let params = notification + .params + .ok_or_else(|| anyhow!("item/plan/delta notifications must include params"))?; + let payload: PlanDeltaNotification = serde_json::from_value(params)?; + plan_deltas.push(payload); + } + "turn/completed" => { + let params = notification + .params + .ok_or_else(|| anyhow!("turn/completed notifications must include params"))?; + let payload: TurnCompletedNotification = serde_json::from_value(params)?; + return Ok((started_items, completed_items, plan_deltas, payload)); + } + _ => {} + } + } +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let features = BTreeMap::from([ + (Feature::RemoteModels, false), + (Feature::CollaborationModes, true), + ]); + let feature_entries = features + .into_iter() + .map(|(feature, enabled)| { + let key = FEATURES + .iter() + .find(|spec| spec.id == feature) + .map(|spec| spec.key) + .unwrap_or_else(|| panic!("missing feature key for {feature:?}")); + format!("{key} = {enabled}") + }) + .collect::>() + .join("\n"); + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +{feature_entries} + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 611c0d164c..151c6fa0fb 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -146,6 +146,7 @@ mod tests { use crate::config::Config; use crate::config::ConfigBuilder; use assert_matches::assert_matches; + use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnAbortReason; @@ -231,6 +232,7 @@ mod tests { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, })); assert_eq!(status, Some(AgentStatus::Running)); } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 324f41a04e..30dd8d24fc 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -30,6 +30,7 @@ use crate::rollout::session_index; use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::handle_non_tool_response_item; use crate::stream_events_utils::handle_output_item_done; +use crate::stream_events_utils::last_assistant_message_from_item; use crate::terminal; use crate::transport_manager::TransportManager; use crate::truncate::TruncationPolicy; @@ -44,6 +45,7 @@ use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::items::PlanItem; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::BaseInstructions; @@ -127,6 +129,9 @@ use crate::mentions::collect_explicit_app_paths; use crate::mentions::collect_tool_mentions_from_messages; use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY; use crate::project_doc::get_user_instructions; +use crate::proposed_plan_parser::ProposedPlanParser; +use crate::proposed_plan_parser::ProposedPlanSegment; +use crate::proposed_plan_parser::extract_proposed_plan_text; use crate::protocol::AgentMessageContentDeltaEvent; use crate::protocol::AgentReasoningSectionBreakEvent; use crate::protocol::ApplyPatchApprovalRequestEvent; @@ -139,6 +144,7 @@ use crate::protocol::EventMsg; use crate::protocol::ExecApprovalRequestEvent; use crate::protocol::McpServerRefreshConfig; use crate::protocol::Op; +use crate::protocol::PlanDeltaEvent; use crate::protocol::RateLimitSnapshot; use crate::protocol::ReasoningContentDeltaEvent; use crate::protocol::ReasoningRawContentDeltaEvent; @@ -482,6 +488,7 @@ pub(crate) struct TurnContext { pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, + pub(crate) collaboration_mode_kind: ModeKind, pub(crate) personality: Option, pub(crate) approval_policy: AskForApproval, pub(crate) sandbox_policy: SandboxPolicy, @@ -682,6 +689,7 @@ impl Session { developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), + collaboration_mode_kind: session_configuration.collaboration_mode.mode, personality: session_configuration.personality, approval_policy: session_configuration.approval_policy.value(), sandbox_policy: session_configuration.sandbox_policy.get().clone(), @@ -3196,6 +3204,7 @@ async fn spawn_review_thread( developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), + collaboration_mode_kind: parent_turn_context.collaboration_mode_kind, personality: parent_turn_context.personality, approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), @@ -3310,6 +3319,7 @@ pub(crate) async fn run_turn( let total_usage_tokens = sess.get_total_token_usage().await; let event = EventMsg::TurnStarted(TurnStartedEvent { model_context_window: turn_context.client.get_model_context_window(), + collaboration_mode_kind: turn_context.collaboration_mode_kind, }); sess.send_event(&turn_context, event).await; if total_usage_tokens >= auto_compact_limit { @@ -3759,6 +3769,381 @@ struct SamplingRequestResult { last_agent_message: Option, } +/// Ephemeral per-response state for streaming a single proposed plan. +/// This is intentionally not persisted or stored in session/state since it +/// only exists while a response is actively streaming. The final plan text +/// is extracted from the completed assistant message. +/// Tracks a single proposed plan item across a streaming response. +struct ProposedPlanItemState { + item_id: String, + started: bool, + completed: bool, +} + +/// Per-item plan parsers so we can buffer text while detecting `` +/// tags without ever mixing buffered lines across item ids. +struct PlanParsers { + assistant: HashMap, +} + +impl PlanParsers { + fn new() -> Self { + Self { + assistant: HashMap::new(), + } + } + + fn assistant_parser_mut(&mut self, item_id: &str) -> &mut ProposedPlanParser { + self.assistant + .entry(item_id.to_string()) + .or_insert_with(ProposedPlanParser::new) + } + + fn take_assistant_parser(&mut self, item_id: &str) -> Option { + self.assistant.remove(item_id) + } + + fn drain_assistant_parsers(&mut self) -> Vec<(String, ProposedPlanParser)> { + self.assistant.drain().collect() + } +} + +/// Aggregated state used only while streaming a plan-mode response. +/// Includes per-item parsers, deferred agent message bookkeeping, and the plan item lifecycle. +struct PlanModeStreamState { + /// Per-item parsers for assistant streams in plan mode. + plan_parsers: PlanParsers, + /// Agent message items started by the model but deferred until we see non-plan text. + pending_agent_message_items: HashMap, + /// Agent message items whose start notification has been emitted. + started_agent_message_items: HashSet, + /// Leading whitespace buffered until we see non-whitespace text for an item. + leading_whitespace_by_item: HashMap, + /// Tracks plan item lifecycle while streaming plan output. + plan_item_state: ProposedPlanItemState, +} + +impl PlanModeStreamState { + fn new(turn_id: &str) -> Self { + Self { + plan_parsers: PlanParsers::new(), + pending_agent_message_items: HashMap::new(), + started_agent_message_items: HashSet::new(), + leading_whitespace_by_item: HashMap::new(), + plan_item_state: ProposedPlanItemState::new(turn_id), + } + } +} + +impl ProposedPlanItemState { + fn new(turn_id: &str) -> Self { + Self { + item_id: format!("{turn_id}-plan"), + started: false, + completed: false, + } + } + + async fn start(&mut self, sess: &Session, turn_context: &TurnContext) { + if self.started || self.completed { + return; + } + self.started = true; + let item = TurnItem::Plan(PlanItem { + id: self.item_id.clone(), + text: String::new(), + }); + sess.emit_turn_item_started(turn_context, &item).await; + } + + async fn push_delta(&mut self, sess: &Session, turn_context: &TurnContext, delta: &str) { + if self.completed { + return; + } + if delta.is_empty() { + return; + } + let event = PlanDeltaEvent { + thread_id: sess.conversation_id.to_string(), + turn_id: turn_context.sub_id.clone(), + item_id: self.item_id.clone(), + delta: delta.to_string(), + }; + sess.send_event(turn_context, EventMsg::PlanDelta(event)) + .await; + } + + async fn complete_with_text( + &mut self, + sess: &Session, + turn_context: &TurnContext, + text: String, + ) { + if self.completed || !self.started { + return; + } + self.completed = true; + let item = TurnItem::Plan(PlanItem { + id: self.item_id.clone(), + text, + }); + sess.emit_turn_item_completed(turn_context, item).await; + } +} + +/// In plan mode we defer agent message starts until the parser emits non-plan +/// text. The parser buffers each line until it can rule out a tag prefix, so +/// plan-only outputs never show up as empty assistant messages. +async fn maybe_emit_pending_agent_message_start( + sess: &Session, + turn_context: &TurnContext, + state: &mut PlanModeStreamState, + item_id: &str, +) { + if state.started_agent_message_items.contains(item_id) { + return; + } + if let Some(item) = state.pending_agent_message_items.remove(item_id) { + sess.emit_turn_item_started(turn_context, &item).await; + state + .started_agent_message_items + .insert(item_id.to_string()); + } +} + +/// Agent messages are text-only today; concatenate all text entries. +fn agent_message_text(item: &codex_protocol::items::AgentMessageItem) -> String { + item.content + .iter() + .map(|entry| match entry { + codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), + }) + .collect() +} + +/// Split the stream into normal assistant text vs. proposed plan content. +/// Normal text becomes AgentMessage deltas; plan content becomes PlanDelta + +/// TurnItem::Plan. +async fn handle_plan_segments( + sess: &Session, + turn_context: &TurnContext, + state: &mut PlanModeStreamState, + item_id: &str, + segments: Vec, +) { + for segment in segments { + match segment { + ProposedPlanSegment::Normal(delta) => { + if delta.is_empty() { + continue; + } + let has_non_whitespace = delta.chars().any(|ch| !ch.is_whitespace()); + if !has_non_whitespace && !state.started_agent_message_items.contains(item_id) { + let entry = state + .leading_whitespace_by_item + .entry(item_id.to_string()) + .or_default(); + entry.push_str(&delta); + continue; + } + let delta = if !state.started_agent_message_items.contains(item_id) { + if let Some(prefix) = state.leading_whitespace_by_item.remove(item_id) { + format!("{prefix}{delta}") + } else { + delta + } + } else { + delta + }; + maybe_emit_pending_agent_message_start(sess, turn_context, state, item_id).await; + + let event = AgentMessageContentDeltaEvent { + thread_id: sess.conversation_id.to_string(), + turn_id: turn_context.sub_id.clone(), + item_id: item_id.to_string(), + delta, + }; + sess.send_event(turn_context, EventMsg::AgentMessageContentDelta(event)) + .await; + } + ProposedPlanSegment::ProposedPlanStart => { + if !state.plan_item_state.completed { + state.plan_item_state.start(sess, turn_context).await; + } + } + ProposedPlanSegment::ProposedPlanDelta(delta) => { + if !state.plan_item_state.completed { + if !state.plan_item_state.started { + state.plan_item_state.start(sess, turn_context).await; + } + state + .plan_item_state + .push_delta(sess, turn_context, &delta) + .await; + } + } + ProposedPlanSegment::ProposedPlanEnd => {} + } + } +} + +/// Flush any buffered proposed-plan segments when a specific assistant message ends. +async fn flush_proposed_plan_segments_for_item( + sess: &Session, + turn_context: &TurnContext, + state: &mut PlanModeStreamState, + item_id: &str, +) { + let Some(mut parser) = state.plan_parsers.take_assistant_parser(item_id) else { + return; + }; + let segments = parser.finish(); + if segments.is_empty() { + return; + } + handle_plan_segments(sess, turn_context, state, item_id, segments).await; +} + +/// Flush any remaining assistant plan parsers when the response completes. +async fn flush_proposed_plan_segments_all( + sess: &Session, + turn_context: &TurnContext, + state: &mut PlanModeStreamState, +) { + for (item_id, mut parser) in state.plan_parsers.drain_assistant_parsers() { + let segments = parser.finish(); + if segments.is_empty() { + continue; + } + handle_plan_segments(sess, turn_context, state, &item_id, segments).await; + } +} + +/// Emit completion for plan items by parsing the finalized assistant message. +async fn maybe_complete_plan_item_from_message( + sess: &Session, + turn_context: &TurnContext, + state: &mut PlanModeStreamState, + item: &ResponseItem, +) { + if let ResponseItem::Message { role, content, .. } = item + && role == "assistant" + { + let mut text = String::new(); + for entry in content { + if let ContentItem::OutputText { text: chunk } = entry { + text.push_str(chunk); + } + } + if let Some(plan_text) = extract_proposed_plan_text(&text) { + if !state.plan_item_state.started { + state.plan_item_state.start(sess, turn_context).await; + } + state + .plan_item_state + .complete_with_text(sess, turn_context, plan_text) + .await; + } + } +} + +/// Emit a completed agent message in plan mode, respecting deferred starts. +async fn emit_agent_message_in_plan_mode( + sess: &Session, + turn_context: &TurnContext, + agent_message: codex_protocol::items::AgentMessageItem, + state: &mut PlanModeStreamState, +) { + let agent_message_id = agent_message.id.clone(); + let text = agent_message_text(&agent_message); + if text.trim().is_empty() { + state.pending_agent_message_items.remove(&agent_message_id); + state.started_agent_message_items.remove(&agent_message_id); + return; + } + + maybe_emit_pending_agent_message_start(sess, turn_context, state, &agent_message_id).await; + + if !state + .started_agent_message_items + .contains(&agent_message_id) + { + let start_item = state + .pending_agent_message_items + .remove(&agent_message_id) + .unwrap_or_else(|| { + TurnItem::AgentMessage(codex_protocol::items::AgentMessageItem { + id: agent_message_id.clone(), + content: Vec::new(), + }) + }); + sess.emit_turn_item_started(turn_context, &start_item).await; + state + .started_agent_message_items + .insert(agent_message_id.clone()); + } + + sess.emit_turn_item_completed(turn_context, TurnItem::AgentMessage(agent_message)) + .await; + state.started_agent_message_items.remove(&agent_message_id); +} + +/// Emit completion for a plan-mode turn item, handling agent messages specially. +async fn emit_turn_item_in_plan_mode( + sess: &Session, + turn_context: &TurnContext, + turn_item: TurnItem, + previously_active_item: Option<&TurnItem>, + state: &mut PlanModeStreamState, +) { + match turn_item { + TurnItem::AgentMessage(agent_message) => { + emit_agent_message_in_plan_mode(sess, turn_context, agent_message, state).await; + } + _ => { + if previously_active_item.is_none() { + sess.emit_turn_item_started(turn_context, &turn_item).await; + } + sess.emit_turn_item_completed(turn_context, turn_item).await; + } + } +} + +/// Handle a completed assistant response item in plan mode, returning true if handled. +async fn handle_assistant_item_done_in_plan_mode( + sess: &Session, + turn_context: &TurnContext, + item: &ResponseItem, + state: &mut PlanModeStreamState, + previously_active_item: Option<&TurnItem>, + last_agent_message: &mut Option, +) -> bool { + if let ResponseItem::Message { role, .. } = item + && role == "assistant" + { + maybe_complete_plan_item_from_message(sess, turn_context, state, item).await; + + if let Some(turn_item) = handle_non_tool_response_item(item, true).await { + emit_turn_item_in_plan_mode( + sess, + turn_context, + turn_item, + previously_active_item, + state, + ) + .await; + } + + sess.record_conversation_items(turn_context, std::slice::from_ref(item)) + .await; + if let Some(agent_message) = last_assistant_message_from_item(item, true) { + *last_agent_message = Some(agent_message); + } + return true; + } + false +} + async fn drain_in_flight( in_flight: &mut FuturesOrdered>>, sess: Arc, @@ -3795,10 +4180,6 @@ async fn try_run_sampling_request( prompt: &Prompt, cancellation_token: CancellationToken, ) -> CodexResult { - // TODO: If we need to guarantee the persisted mode always matches the prompt used for this - // turn, capture it in TurnContext at creation time. Using SessionConfiguration here avoids - // duplicating model settings on TurnContext, but a later Op could update the session config - // before this write occurs. let collaboration_mode = sess.current_collaboration_mode().await; let rollout_item = RolloutItem::TurnContext(TurnContextItem { cwd: turn_context.cwd.clone(), @@ -3843,6 +4224,8 @@ async fn try_run_sampling_request( let mut last_agent_message: Option = None; let mut active_item: Option = None; let mut should_emit_turn_diff = false; + let plan_mode = turn_context.collaboration_mode_kind == ModeKind::Plan; + let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id)); let receiving_span = trace_span!("receiving_stream"); let outcome: CodexResult = loop { let handle_responses = trace_span!( @@ -3881,6 +4264,33 @@ async fn try_run_sampling_request( ResponseEvent::Created => {} ResponseEvent::OutputItemDone(item) => { let previously_active_item = active_item.take(); + if let Some(state) = plan_mode_state.as_mut() { + if let Some(previous) = previously_active_item.as_ref() { + let item_id = previous.id(); + if matches!(previous, TurnItem::AgentMessage(_)) { + flush_proposed_plan_segments_for_item( + &sess, + &turn_context, + state, + &item_id, + ) + .await; + } + } + if handle_assistant_item_done_in_plan_mode( + &sess, + &turn_context, + &item, + state, + previously_active_item.as_ref(), + &mut last_agent_message, + ) + .await + { + continue; + } + } + let mut ctx = HandleOutputCtx { sess: sess.clone(), turn_context: turn_context.clone(), @@ -3900,8 +4310,17 @@ async fn try_run_sampling_request( needs_follow_up |= output_result.needs_follow_up; } ResponseEvent::OutputItemAdded(item) => { - if let Some(turn_item) = handle_non_tool_response_item(&item).await { - sess.emit_turn_item_started(&turn_context, &turn_item).await; + if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode).await { + if let Some(state) = plan_mode_state.as_mut() + && matches!(turn_item, TurnItem::AgentMessage(_)) + { + let item_id = turn_item.id(); + state + .pending_agent_message_items + .insert(item_id, turn_item.clone()); + } else { + sess.emit_turn_item_started(&turn_context, &turn_item).await; + } active_item = Some(turn_item); } } @@ -3925,6 +4344,9 @@ async fn try_run_sampling_request( response_id: _, token_usage, } => { + if let Some(state) = plan_mode_state.as_mut() { + flush_proposed_plan_segments_all(&sess, &turn_context, state).await; + } sess.update_token_usage_info(&turn_context, token_usage.as_ref()) .await; should_emit_turn_diff = true; @@ -3940,14 +4362,25 @@ async fn try_run_sampling_request( // In review child threads, suppress assistant text deltas; the // UI will show a selection popup from the final ReviewOutput. if let Some(active) = active_item.as_ref() { - let event = AgentMessageContentDeltaEvent { - thread_id: sess.conversation_id.to_string(), - turn_id: turn_context.sub_id.clone(), - item_id: active.id(), - delta: delta.clone(), - }; - sess.send_event(&turn_context, EventMsg::AgentMessageContentDelta(event)) - .await; + let item_id = active.id(); + if let Some(state) = plan_mode_state.as_mut() + && matches!(active, TurnItem::AgentMessage(_)) + { + let segments = state + .plan_parsers + .assistant_parser_mut(&item_id) + .parse(&delta); + handle_plan_segments(&sess, &turn_context, state, &item_id, segments).await; + } else { + let event = AgentMessageContentDeltaEvent { + thread_id: sess.conversation_id.to_string(), + turn_id: turn_context.sub_id.clone(), + item_id, + delta, + }; + sess.send_event(&turn_context, EventMsg::AgentMessageContentDelta(event)) + .await; + } } else { error_or_panic("OutputTextDelta without active item".to_string()); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index b233618eea..af9aacc769 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -61,6 +61,7 @@ pub(crate) async fn run_compact_task( ) { let start_event = EventMsg::TurnStarted(TurnStartedEvent { model_context_window: turn_context.client.get_model_context_window(), + collaboration_mode_kind: turn_context.collaboration_mode_kind, }); sess.send_event(&turn_context, start_event).await; run_compact_task_inner(sess.clone(), turn_context, input).await; diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 35c157acfc..b45a2769dc 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -22,6 +22,7 @@ pub(crate) async fn run_inline_remote_auto_compact_task( pub(crate) async fn run_remote_compact_task(sess: Arc, turn_context: Arc) { let start_event = EventMsg::TurnStarted(TurnStartedEvent { model_context_window: turn_context.client.get_model_context_window(), + collaboration_mode_kind: turn_context.collaboration_mode_kind, }); sess.send_event(&turn_context, start_event).await; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 63bba76507..ba47838e1f 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -49,9 +49,11 @@ mod model_provider_info; pub mod parse_command; pub mod path_utils; pub mod powershell; +mod proposed_plan_parser; pub mod sandboxing; mod session_prefix; mod stream_events_utils; +mod tagged_block_parser; mod text_encoding; pub mod token_data; mod truncate; diff --git a/codex-rs/core/src/proposed_plan_parser.rs b/codex-rs/core/src/proposed_plan_parser.rs new file mode 100644 index 0000000000..44be264f29 --- /dev/null +++ b/codex-rs/core/src/proposed_plan_parser.rs @@ -0,0 +1,185 @@ +use crate::tagged_block_parser::TagSpec; +use crate::tagged_block_parser::TaggedLineParser; +use crate::tagged_block_parser::TaggedLineSegment; + +const OPEN_TAG: &str = ""; +const CLOSE_TAG: &str = ""; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PlanTag { + ProposedPlan, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ProposedPlanSegment { + Normal(String), + ProposedPlanStart, + ProposedPlanDelta(String), + ProposedPlanEnd, +} + +/// Parser for `` blocks emitted in plan mode. +/// +/// This is a thin wrapper around the generic line-based tag parser. It maps +/// tag-aware segments into plan-specific segments for downstream consumers. +#[derive(Debug)] +pub(crate) struct ProposedPlanParser { + parser: TaggedLineParser, +} + +impl ProposedPlanParser { + pub(crate) fn new() -> Self { + Self { + parser: TaggedLineParser::new(vec![TagSpec { + open: OPEN_TAG, + close: CLOSE_TAG, + tag: PlanTag::ProposedPlan, + }]), + } + } + + pub(crate) fn parse(&mut self, delta: &str) -> Vec { + self.parser + .parse(delta) + .into_iter() + .map(map_plan_segment) + .collect() + } + + pub(crate) fn finish(&mut self) -> Vec { + self.parser + .finish() + .into_iter() + .map(map_plan_segment) + .collect() + } +} + +fn map_plan_segment(segment: TaggedLineSegment) -> ProposedPlanSegment { + match segment { + TaggedLineSegment::Normal(text) => ProposedPlanSegment::Normal(text), + TaggedLineSegment::TagStart(PlanTag::ProposedPlan) => { + ProposedPlanSegment::ProposedPlanStart + } + TaggedLineSegment::TagDelta(PlanTag::ProposedPlan, text) => { + ProposedPlanSegment::ProposedPlanDelta(text) + } + TaggedLineSegment::TagEnd(PlanTag::ProposedPlan) => ProposedPlanSegment::ProposedPlanEnd, + } +} + +pub(crate) fn strip_proposed_plan_blocks(text: &str) -> String { + let mut parser = ProposedPlanParser::new(); + let mut out = String::new(); + for segment in parser.parse(text).into_iter().chain(parser.finish()) { + if let ProposedPlanSegment::Normal(delta) = segment { + out.push_str(&delta); + } + } + out +} + +pub(crate) fn extract_proposed_plan_text(text: &str) -> Option { + let mut parser = ProposedPlanParser::new(); + let mut plan_text = String::new(); + let mut saw_plan_block = false; + for segment in parser.parse(text).into_iter().chain(parser.finish()) { + match segment { + ProposedPlanSegment::ProposedPlanStart => { + saw_plan_block = true; + plan_text.clear(); + } + ProposedPlanSegment::ProposedPlanDelta(delta) => { + plan_text.push_str(&delta); + } + ProposedPlanSegment::ProposedPlanEnd | ProposedPlanSegment::Normal(_) => {} + } + } + saw_plan_block.then_some(plan_text) +} + +#[cfg(test)] +mod tests { + use super::ProposedPlanParser; + use super::ProposedPlanSegment; + use super::strip_proposed_plan_blocks; + use pretty_assertions::assert_eq; + + #[test] + fn streams_proposed_plan_segments() { + let mut parser = ProposedPlanParser::new(); + let mut segments = Vec::new(); + + for chunk in [ + "Intro text\n\n- step 1\n", + "\nOutro", + ] { + segments.extend(parser.parse(chunk)); + } + segments.extend(parser.finish()); + + assert_eq!( + segments, + vec![ + ProposedPlanSegment::Normal("Intro text\n".to_string()), + ProposedPlanSegment::ProposedPlanStart, + ProposedPlanSegment::ProposedPlanDelta("- step 1\n".to_string()), + ProposedPlanSegment::ProposedPlanEnd, + ProposedPlanSegment::Normal("Outro".to_string()), + ] + ); + } + + #[test] + fn preserves_non_tag_lines() { + let mut parser = ProposedPlanParser::new(); + let mut segments = parser.parse(" extra\n"); + segments.extend(parser.finish()); + + assert_eq!( + segments, + vec![ProposedPlanSegment::Normal( + " extra\n".to_string() + )] + ); + } + + #[test] + fn closes_unterminated_plan_block_on_finish() { + let mut parser = ProposedPlanParser::new(); + let mut segments = parser.parse("\n- step 1\n"); + segments.extend(parser.finish()); + + assert_eq!( + segments, + vec![ + ProposedPlanSegment::ProposedPlanStart, + ProposedPlanSegment::ProposedPlanDelta("- step 1\n".to_string()), + ProposedPlanSegment::ProposedPlanEnd, + ] + ); + } + + #[test] + fn closes_tag_line_without_trailing_newline() { + let mut parser = ProposedPlanParser::new(); + let mut segments = parser.parse("\n- step 1\n"); + segments.extend(parser.finish()); + + assert_eq!( + segments, + vec![ + ProposedPlanSegment::ProposedPlanStart, + ProposedPlanSegment::ProposedPlanDelta("- step 1\n".to_string()), + ProposedPlanSegment::ProposedPlanEnd, + ] + ); + } + + #[test] + fn strips_proposed_plan_blocks_from_text() { + let text = "before\n\n- step\n\nafter"; + assert_eq!(strip_proposed_plan_blocks(text), "before\nafter"); + } +} diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 6de60ae05c..5796bd1961 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -48,6 +48,12 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::ThreadRolledBack(_) | EventMsg::UndoCompleted(_) | EventMsg::TurnAborted(_) => true, + EventMsg::ItemCompleted(event) => { + // Plan items are derived from streaming tags and are not part of the + // raw ResponseItem history, so we persist their completion to replay + // them on resume without bloating rollouts with every item lifecycle. + matches!(event.item, codex_protocol::items::TurnItem::Plan(_)) + } EventMsg::Error(_) | EventMsg::Warning(_) | EventMsg::TurnStarted(_) @@ -89,8 +95,8 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::ViewImageToolCall(_) | EventMsg::DeprecationNotice(_) | EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) + | EventMsg::PlanDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) | EventMsg::SkillsUpdateAvailable diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 930ce5afd6..91f7efdb01 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -1,6 +1,7 @@ use std::pin::Pin; use std::sync::Arc; +use codex_protocol::config_types::ModeKind; use codex_protocol::items::TurnItem; use tokio_util::sync::CancellationToken; @@ -10,6 +11,7 @@ use crate::error::CodexErr; use crate::error::Result; use crate::function_tool::FunctionCallError; use crate::parse_turn_item; +use crate::proposed_plan_parser::strip_proposed_plan_blocks; use crate::tools::parallel::ToolCallRuntime; use crate::tools::router::ToolRouter; use codex_protocol::models::FunctionCallOutputPayload; @@ -46,6 +48,7 @@ pub(crate) async fn handle_output_item_done( previously_active_item: Option, ) -> Result { let mut output = OutputItemResult::default(); + let plan_mode = ctx.turn_context.collaboration_mode_kind == ModeKind::Plan; match ToolRouter::build_tool_call(ctx.sess.as_ref(), item.clone()).await { // The model emitted a tool call; log it, persist the item immediately, and queue the tool execution. @@ -74,7 +77,7 @@ pub(crate) async fn handle_output_item_done( } // No tool call: convert messages/reasoning into turn items and mark them as complete. Ok(None) => { - if let Some(turn_item) = handle_non_tool_response_item(&item).await { + if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode).await { if previously_active_item.is_none() { ctx.sess .emit_turn_item_started(&ctx.turn_context, &turn_item) @@ -89,7 +92,7 @@ pub(crate) async fn handle_output_item_done( ctx.sess .record_conversation_items(&ctx.turn_context, std::slice::from_ref(&item)) .await; - let last_agent_message = last_assistant_message_from_item(&item); + let last_agent_message = last_assistant_message_from_item(&item, plan_mode); output.last_agent_message = last_agent_message; } @@ -155,13 +158,31 @@ pub(crate) async fn handle_output_item_done( Ok(output) } -pub(crate) async fn handle_non_tool_response_item(item: &ResponseItem) -> Option { +pub(crate) async fn handle_non_tool_response_item( + item: &ResponseItem, + plan_mode: bool, +) -> Option { debug!(?item, "Output item"); match item { ResponseItem::Message { .. } | ResponseItem::Reasoning { .. } - | ResponseItem::WebSearchCall { .. } => parse_turn_item(item), + | ResponseItem::WebSearchCall { .. } => { + let mut turn_item = parse_turn_item(item)?; + if plan_mode && let TurnItem::AgentMessage(agent_message) = &mut turn_item { + let combined = agent_message + .content + .iter() + .map(|entry| match entry { + codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), + }) + .collect::(); + let stripped = strip_proposed_plan_blocks(&combined); + agent_message.content = + vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }]; + } + Some(turn_item) + } ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } => { debug!("unexpected tool output from stream"); None @@ -170,14 +191,29 @@ pub(crate) async fn handle_non_tool_response_item(item: &ResponseItem) -> Option } } -pub(crate) fn last_assistant_message_from_item(item: &ResponseItem) -> Option { +pub(crate) fn last_assistant_message_from_item( + item: &ResponseItem, + plan_mode: bool, +) -> Option { if let ResponseItem::Message { role, content, .. } = item && role == "assistant" { - return content.iter().rev().find_map(|ci| match ci { - codex_protocol::models::ContentItem::OutputText { text } => Some(text.clone()), - _ => None, - }); + let combined = content + .iter() + .filter_map(|ci| match ci { + codex_protocol::models::ContentItem::OutputText { text } => Some(text.as_str()), + _ => None, + }) + .collect::(); + if combined.is_empty() { + return None; + } + return if plan_mode { + let stripped = strip_proposed_plan_blocks(&combined); + (!stripped.trim().is_empty()).then_some(stripped) + } else { + Some(combined) + }; } None } diff --git a/codex-rs/core/src/tagged_block_parser.rs b/codex-rs/core/src/tagged_block_parser.rs new file mode 100644 index 0000000000..46ec012c30 --- /dev/null +++ b/codex-rs/core/src/tagged_block_parser.rs @@ -0,0 +1,314 @@ +//! Line-based tag block parsing for streamed text. +//! +//! The parser buffers each line until it can disprove that the line is a tag, +//! which is required for tags that must appear alone on a line. For example, +//! Proposed Plan output uses `` and `` tags +//! on their own lines so clients can stream plan content separately. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct TagSpec { + pub(crate) open: &'static str, + pub(crate) close: &'static str, + pub(crate) tag: T, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TaggedLineSegment { + Normal(String), + TagStart(T), + TagDelta(T, String), + TagEnd(T), +} + +/// Stateful line parser that splits input into normal text vs tag blocks. +/// +/// How it works: +/// - While reading a line, we buffer characters until the line either finishes +/// (`\n`) or stops matching any tag prefix (after `trim_start`). +/// - If it stops matching a tag prefix, the buffered line is immediately +/// emitted as text and we continue in "plain text" mode until the next +/// newline. +/// - When a full line is available, we compare it to the open/close tags; tag +/// lines emit TagStart/TagEnd, otherwise the line is emitted as text. +/// - `finish()` flushes any buffered line and auto-closes an unterminated tag, +/// which keeps streaming resilient to missing closing tags. +#[derive(Debug, Default)] +pub(crate) struct TaggedLineParser +where + T: Copy + Eq, +{ + specs: Vec>, + active_tag: Option, + detect_tag: bool, + line_buffer: String, +} + +impl TaggedLineParser +where + T: Copy + Eq, +{ + pub(crate) fn new(specs: Vec>) -> Self { + Self { + specs, + active_tag: None, + detect_tag: true, + line_buffer: String::new(), + } + } + + /// Parse a streamed delta into line-aware segments. + pub(crate) fn parse(&mut self, delta: &str) -> Vec> { + let mut segments = Vec::new(); + let mut run = String::new(); + + for ch in delta.chars() { + if self.detect_tag { + if !run.is_empty() { + self.push_text(std::mem::take(&mut run), &mut segments); + } + self.line_buffer.push(ch); + if ch == '\n' { + self.finish_line(&mut segments); + continue; + } + let slug = self.line_buffer.trim_start(); + if slug.is_empty() || self.is_tag_prefix(slug) { + continue; + } + // This line cannot be a tag line, so flush it immediately. + let buffered = std::mem::take(&mut self.line_buffer); + self.detect_tag = false; + self.push_text(buffered, &mut segments); + continue; + } + + run.push(ch); + if ch == '\n' { + self.push_text(std::mem::take(&mut run), &mut segments); + self.detect_tag = true; + } + } + + if !run.is_empty() { + self.push_text(run, &mut segments); + } + + segments + } + + /// Flush any buffered text and close an unterminated tag block. + pub(crate) fn finish(&mut self) -> Vec> { + let mut segments = Vec::new(); + if !self.line_buffer.is_empty() { + let buffered = std::mem::take(&mut self.line_buffer); + let without_newline = buffered.strip_suffix('\n').unwrap_or(&buffered); + let slug = without_newline.trim_start().trim_end(); + + if let Some(tag) = self.match_open(slug) + && self.active_tag.is_none() + { + push_segment(&mut segments, TaggedLineSegment::TagStart(tag)); + self.active_tag = Some(tag); + } else if let Some(tag) = self.match_close(slug) + && self.active_tag == Some(tag) + { + push_segment(&mut segments, TaggedLineSegment::TagEnd(tag)); + self.active_tag = None; + } else { + // The buffered line never proved to be a tag line. + self.push_text(buffered, &mut segments); + } + } + if let Some(tag) = self.active_tag.take() { + push_segment(&mut segments, TaggedLineSegment::TagEnd(tag)); + } + self.detect_tag = true; + segments + } + + fn finish_line(&mut self, segments: &mut Vec>) { + let line = std::mem::take(&mut self.line_buffer); + let without_newline = line.strip_suffix('\n').unwrap_or(&line); + let slug = without_newline.trim_start().trim_end(); + + if let Some(tag) = self.match_open(slug) + && self.active_tag.is_none() + { + push_segment(segments, TaggedLineSegment::TagStart(tag)); + self.active_tag = Some(tag); + self.detect_tag = true; + return; + } + + if let Some(tag) = self.match_close(slug) + && self.active_tag == Some(tag) + { + push_segment(segments, TaggedLineSegment::TagEnd(tag)); + self.active_tag = None; + self.detect_tag = true; + return; + } + + self.detect_tag = true; + self.push_text(line, segments); + } + + fn push_text(&self, text: String, segments: &mut Vec>) { + if let Some(tag) = self.active_tag { + push_segment(segments, TaggedLineSegment::TagDelta(tag, text)); + } else { + push_segment(segments, TaggedLineSegment::Normal(text)); + } + } + + fn is_tag_prefix(&self, slug: &str) -> bool { + let slug = slug.trim_end(); + self.specs + .iter() + .any(|spec| spec.open.starts_with(slug) || spec.close.starts_with(slug)) + } + + fn match_open(&self, slug: &str) -> Option { + self.specs + .iter() + .find(|spec| spec.open == slug) + .map(|spec| spec.tag) + } + + fn match_close(&self, slug: &str) -> Option { + self.specs + .iter() + .find(|spec| spec.close == slug) + .map(|spec| spec.tag) + } +} + +fn push_segment(segments: &mut Vec>, segment: TaggedLineSegment) +where + T: Copy + Eq, +{ + match segment { + TaggedLineSegment::Normal(delta) => { + if delta.is_empty() { + return; + } + if let Some(TaggedLineSegment::Normal(existing)) = segments.last_mut() { + existing.push_str(&delta); + return; + } + segments.push(TaggedLineSegment::Normal(delta)); + } + TaggedLineSegment::TagDelta(tag, delta) => { + if delta.is_empty() { + return; + } + if let Some(TaggedLineSegment::TagDelta(existing_tag, existing)) = segments.last_mut() + && *existing_tag == tag + { + existing.push_str(&delta); + return; + } + segments.push(TaggedLineSegment::TagDelta(tag, delta)); + } + TaggedLineSegment::TagStart(tag) => { + segments.push(TaggedLineSegment::TagStart(tag)); + } + TaggedLineSegment::TagEnd(tag) => { + segments.push(TaggedLineSegment::TagEnd(tag)); + } + } +} + +#[cfg(test)] +mod tests { + use super::TagSpec; + use super::TaggedLineParser; + use super::TaggedLineSegment; + use pretty_assertions::assert_eq; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Tag { + Block, + } + + fn parser() -> TaggedLineParser { + TaggedLineParser::new(vec![TagSpec { + open: "", + close: "", + tag: Tag::Block, + }]) + } + + #[test] + fn buffers_prefix_until_tag_is_decided() { + let mut parser = parser(); + let mut segments = parser.parse("\nline\n\n")); + segments.extend(parser.finish()); + + assert_eq!( + segments, + vec![ + TaggedLineSegment::TagStart(Tag::Block), + TaggedLineSegment::TagDelta(Tag::Block, "line\n".to_string()), + TaggedLineSegment::TagEnd(Tag::Block), + ] + ); + } + + #[test] + fn rejects_tag_lines_with_extra_text() { + let mut parser = parser(); + let mut segments = parser.parse(" extra\n"); + segments.extend(parser.finish()); + + assert_eq!( + segments, + vec![TaggedLineSegment::Normal(" extra\n".to_string())] + ); + } + + #[test] + fn closes_unterminated_tag_on_finish() { + let mut parser = parser(); + let mut segments = parser.parse("\nline\n"); + segments.extend(parser.finish()); + + assert_eq!( + segments, + vec![ + TaggedLineSegment::TagStart(Tag::Block), + TaggedLineSegment::TagDelta(Tag::Block, "line\n".to_string()), + TaggedLineSegment::TagEnd(Tag::Block), + ] + ); + } + + #[test] + fn accepts_tags_with_trailing_whitespace() { + let mut parser = parser(); + let mut segments = parser.parse(" \nline\n \n"); + segments.extend(parser.finish()); + + assert_eq!( + segments, + vec![ + TaggedLineSegment::TagStart(Tag::Block), + TaggedLineSegment::TagDelta(Tag::Block, "line\n".to_string()), + TaggedLineSegment::TagEnd(Tag::Block), + ] + ); + } + + #[test] + fn passes_through_plain_text() { + let mut parser = parser(); + let mut segments = parser.parse("plain text\n"); + segments.extend(parser.finish()); + + assert_eq!( + segments, + vec![TaggedLineSegment::Normal("plain text\n".to_string())] + ); + } +} diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 4305417a35..177d122554 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -67,6 +67,7 @@ impl SessionTask for UserShellCommandTask { let event = EventMsg::TurnStarted(TurnStartedEvent { model_context_window: turn_context.client.get_model_context_window(), + collaboration_mode_kind: turn_context.collaboration_mode_kind, }); let session = session.clone_session(); session.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index 073319bf1c..79e332a77e 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -10,6 +10,7 @@ use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use crate::tools::spec::JsonSchema; use async_trait::async_trait; +use codex_protocol::config_types::ModeKind; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::EventMsg; use std::collections::BTreeMap; @@ -103,6 +104,11 @@ pub(crate) async fn handle_update_plan( arguments: String, _call_id: String, ) -> Result { + if turn_context.collaboration_mode_kind == ModeKind::Plan { + return Err(FunctionCallError::RespondToModel( + "update_plan is a TODO/checklist tool and is not allowed in Plan mode".to_string(), + )); + } let args = parse_update_plan_arguments(&arguments)?; session .send_event(turn_context, EventMsg::PlanUpdate(args)) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index cffbf16d36..395b6c368b 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -8,6 +8,12 @@ You are in **Plan Mode** until a developer message explicitly ends it. Plan Mode is not changed by user intent, tone, or imperative language. If a user asks for execution while still in Plan Mode, treat it as a request to **plan the execution**, not perform it. +## Plan Mode vs update_plan tool + +Plan Mode is a collaboration mode that can involve requesting user input and eventually issuing a `` block. + +Separately, `update_plan` is a checklist/progress/TODOs tool; it does not enter or exit Plan Mode. Do not confuse it with Plan mode or try to use it while in Plan mode. If you try to use `update_plan` in Plan mode, it will return an error. + ## Execution vs. mutation in Plan Mode You may explore and execute **non-mutating** actions that improve the plan. You must not perform **mutating** actions. @@ -96,6 +102,22 @@ Use the `request_user_input` tool only for decisions that materially change the Only output the final plan when it is decision complete and leaves no decisions to the implementer. +When you present the official plan, wrap it in a `` block so the client can render it specially: + +1) The opening tag must be on its own line. +2) Start the plan content on the next line (no text on the same line as the tag). +3) The closing tag must be on its own line. +4) Use Markdown inside the block. +5) Keep the tags exactly as `` and `` (do not translate or rename them), even if the plan content is in another language. + +Example: + + +# Plan title +- Step 1 +- Step 2 + + The final plan must be plan-only and include: * A clear title @@ -106,6 +128,6 @@ The final plan must be plan-only and include: * Test cases * Explicit assumptions and defaults chosen where needed -Do not ask "should I proceed?" in the final output. +Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a `` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. -Only produce the final answer when you are presenting the complete spec. +Only produce at most one `` block per turn, and only when you are presenting a complete spec. diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 08ac598388..7ef73dce94 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -5,6 +5,10 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::ItemCompletedEvent; use codex_core::protocol::ItemStartedEvent; use codex_core::protocol::Op; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; +use codex_protocol::items::AgentMessageContent; use codex_protocol::items::TurnItem; use codex_protocol::models::WebSearchAction; use codex_protocol::user_input::ByteRange; @@ -27,6 +31,7 @@ use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; @@ -327,6 +332,268 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let TestCodex { + codex, + session_configured, + .. + } = test_codex().build(&server).await?; + + let plan_block = "\n- Step 1\n- Step 2\n\n"; + let full_message = format!("Intro\n{plan_block}Outro"); + let stream = sse(vec![ + ev_response_created("resp-1"), + ev_message_item_added("msg-1", ""), + ev_output_text_delta(&full_message), + ev_assistant_message("msg-1", &full_message), + ev_completed("resp-1"), + ]); + mount_sse_once(&server, stream).await; + + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please plan".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: std::env::current_dir()?, + approval_policy: codex_core::protocol::AskForApproval::Never, + sandbox_policy: codex_core::protocol::SandboxPolicy::DangerFullAccess, + model: session_configured.model.clone(), + effort: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, + collaboration_mode: Some(collaboration_mode), + personality: None, + }) + .await?; + + let plan_delta = wait_for_event_match(&codex, |ev| match ev { + EventMsg::PlanDelta(event) => Some(event.clone()), + _ => None, + }) + .await; + + let plan_completed = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::Plan(item), + .. + }) => Some(item.clone()), + _ => None, + }) + .await; + + assert_eq!( + plan_delta.thread_id, + session_configured.session_id.to_string() + ); + assert_eq!(plan_delta.delta, "- Step 1\n- Step 2\n"); + assert_eq!(plan_completed.text, "- Step 1\n- Step 2\n"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let TestCodex { + codex, + session_configured, + .. + } = test_codex().build(&server).await?; + + let plan_block = "\n- Step 1\n- Step 2\n\n"; + let full_message = format!("Intro\n{plan_block}Outro"); + let stream = sse(vec![ + ev_response_created("resp-1"), + ev_message_item_added("msg-1", ""), + ev_output_text_delta(&full_message), + ev_assistant_message("msg-1", &full_message), + ev_completed("resp-1"), + ]); + mount_sse_once(&server, stream).await; + + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please plan".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: std::env::current_dir()?, + approval_policy: codex_core::protocol::AskForApproval::Never, + sandbox_policy: codex_core::protocol::SandboxPolicy::DangerFullAccess, + model: session_configured.model.clone(), + effort: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, + collaboration_mode: Some(collaboration_mode), + personality: None, + }) + .await?; + + let mut agent_deltas = Vec::new(); + let mut plan_delta = None; + let mut agent_item = None; + let mut plan_item = None; + + while plan_delta.is_none() || agent_item.is_none() || plan_item.is_none() { + let ev = wait_for_event(&codex, |_| true).await; + match ev { + EventMsg::AgentMessageContentDelta(event) => { + agent_deltas.push(event.delta); + } + EventMsg::PlanDelta(event) => { + plan_delta = Some(event.delta); + } + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::AgentMessage(item), + .. + }) => { + agent_item = Some(item); + } + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::Plan(item), + .. + }) => { + plan_item = Some(item); + } + _ => {} + } + } + + let agent_text = agent_deltas.concat(); + assert_eq!(agent_text, "Intro\nOutro"); + assert_eq!(plan_delta.unwrap(), "- Step 1\n- Step 2\n"); + assert_eq!(plan_item.unwrap().text, "- Step 1\n- Step 2\n"); + let agent_text_from_item: String = agent_item + .unwrap() + .content + .iter() + .map(|entry| match entry { + AgentMessageContent::Text { text } => text.as_str(), + }) + .collect(); + assert_eq!(agent_text_from_item, "Intro\nOutro"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let TestCodex { + codex, + session_configured, + .. + } = test_codex().build(&server).await?; + + let full_message = "Intro\n\n- Step 1\n"; + let stream = sse(vec![ + ev_response_created("resp-1"), + ev_message_item_added("msg-1", ""), + ev_output_text_delta(full_message), + ev_assistant_message("msg-1", full_message), + ev_completed("resp-1"), + ]); + mount_sse_once(&server, stream).await; + + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please plan".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: std::env::current_dir()?, + approval_policy: codex_core::protocol::AskForApproval::Never, + sandbox_policy: codex_core::protocol::SandboxPolicy::DangerFullAccess, + model: session_configured.model.clone(), + effort: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, + collaboration_mode: Some(collaboration_mode), + personality: None, + }) + .await?; + + let mut plan_delta = None; + let mut plan_item = None; + let mut agent_item = None; + + while plan_delta.is_none() || plan_item.is_none() || agent_item.is_none() { + let ev = wait_for_event(&codex, |_| true).await; + match ev { + EventMsg::PlanDelta(event) => { + plan_delta = Some(event.delta); + } + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::Plan(item), + .. + }) => { + plan_item = Some(item); + } + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::AgentMessage(item), + .. + }) => { + agent_item = Some(item); + } + _ => {} + } + } + + assert_eq!(plan_delta.unwrap(), "- Step 1\n"); + assert_eq!(plan_item.unwrap().text, "- Step 1\n"); + let agent_text_from_item: String = agent_item + .unwrap() + .content + .iter() + .map(|entry| match entry { + AgentMessageContent::Text { text } => text.as_str(), + }) + .collect(); + assert_eq!(agent_text_from_item, "Intro\n"); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/debug-client/src/reader.rs b/codex-rs/debug-client/src/reader.rs index 92161638ff..48841f699d 100644 --- a/codex-rs/debug-client/src/reader.rs +++ b/codex-rs/debug-client/src/reader.rs @@ -229,6 +229,11 @@ fn emit_filtered_item(item: ThreadItem, thread_id: &str, output: &Output) -> any let label = output.format_label("assistant", LabelColor::Assistant); output.server_line(&format!("{thread_label} {label}: {text}"))?; } + ThreadItem::Plan { text, .. } => { + let label = output.format_label("assistant", LabelColor::Assistant); + output.server_line(&format!("{thread_label} {label}: plan"))?; + write_multiline(output, &thread_label, &format!("{label}:"), &text)?; + } ThreadItem::CommandExecution { command, status, diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 74b5546012..8f68ecc9a6 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -74,8 +74,11 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op::UserTurn` and `Op::OverrideTurnContext` accept an optional `personality` override that updates the model’s communication style - `EventMsg` - `EventMsg::AgentMessage` – Messages from the `Model` + - `EventMsg::AgentMessageContentDelta` – Streaming assistant text + - `EventMsg::PlanDelta` – Streaming proposed plan text when the model emits a `` block in plan mode - `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command - - `EventMsg::RequestUserInput` – Request user input for a tool call (questions must include options; the client always adds a free-form choice) + - `EventMsg::RequestUserInput` – Request user input for a tool call (questions can include options plus `isOther` to add a free-form choice) + - `EventMsg::TurnStarted` – Turn start metadata including `model_context_window` and `collaboration_mode_kind` - `EventMsg::TurnComplete` – A turn completed successfully - `EventMsg::Error` – A turn stopped with an error - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user 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 e11e1fcadb..8469dc42ed 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -20,6 +20,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::FileChange; +use codex_core::protocol::ItemCompletedEvent; use codex_core::protocol::McpInvocation; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; @@ -33,6 +34,7 @@ use codex_core::protocol::TurnDiffEvent; use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchEndEvent; use codex_core::web_search::web_search_detail; +use codex_protocol::items::TurnItem; use codex_protocol::num_format::format_with_separators; use owo_colors::OwoColorize; use owo_colors::Style; @@ -73,6 +75,7 @@ pub(crate) struct EventProcessorWithHumanOutput { last_message_path: Option, last_total_token_usage: Option, final_message: Option, + last_proposed_plan: Option, } impl EventProcessorWithHumanOutput { @@ -99,6 +102,7 @@ impl EventProcessorWithHumanOutput { last_message_path, last_total_token_usage: None, final_message: None, + last_proposed_plan: None, } } else { Self { @@ -116,6 +120,7 @@ impl EventProcessorWithHumanOutput { last_message_path, last_total_token_usage: None, final_message: None, + last_proposed_plan: None, } } } @@ -260,12 +265,14 @@ impl EventProcessor for EventProcessorWithHumanOutput { ); } EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }) => { - let last_message = last_agent_message.as_deref(); + let last_message = last_agent_message + .as_deref() + .or(self.last_proposed_plan.as_deref()); if let Some(output_file) = self.last_message_path.as_deref() { handle_last_message(last_message, output_file); } - self.final_message = last_agent_message; + self.final_message = last_agent_message.or_else(|| self.last_proposed_plan.clone()); return CodexStatus::InitiateShutdown; } @@ -297,6 +304,12 @@ impl EventProcessor for EventProcessorWithHumanOutput { message, ); } + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::Plan(item), + .. + }) => { + self.last_proposed_plan = Some(item.text); + } EventMsg::ExecCommandBegin(ExecCommandBeginEvent { command, cwd, .. }) => { eprint!( "{}\n{} in {}", @@ -769,6 +782,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) + | EventMsg::PlanDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) | EventMsg::SkillsUpdateAvailable diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 687df574d6..823fa7b810 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -58,6 +58,7 @@ use tracing::warn; pub struct EventProcessorWithJsonOutput { last_message_path: Option, + last_proposed_plan: Option, next_event_id: AtomicU64, // Tracks running commands by call_id, including the associated item id. running_commands: HashMap, @@ -102,6 +103,7 @@ impl EventProcessorWithJsonOutput { pub fn new(last_message_path: Option) -> Self { Self { last_message_path, + last_proposed_plan: None, next_event_id: AtomicU64::new(0), running_commands: HashMap::new(), running_patch_applies: HashMap::new(), @@ -119,6 +121,13 @@ impl EventProcessorWithJsonOutput { protocol::EventMsg::SessionConfigured(ev) => self.handle_session_configured(ev), protocol::EventMsg::ThreadNameUpdated(_) => Vec::new(), protocol::EventMsg::AgentMessage(ev) => self.handle_agent_message(ev), + protocol::EventMsg::ItemCompleted(protocol::ItemCompletedEvent { + item: codex_protocol::items::TurnItem::Plan(item), + .. + }) => { + self.last_proposed_plan = Some(item.text.clone()); + Vec::new() + } protocol::EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), protocol::EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), protocol::EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev), @@ -855,7 +864,10 @@ impl EventProcessor for EventProcessorWithJsonOutput { last_agent_message, }) => { if let Some(output_file) = self.last_message_path.as_deref() { - handle_last_message(last_agent_message.as_deref(), output_file); + let last_message = last_agent_message + .as_deref() + .or(self.last_proposed_plan.as_deref()); + handle_last_message(last_message, output_file); } CodexStatus::InitiateShutdown } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index dc5f220fd3..e405195e7e 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -55,6 +55,7 @@ use codex_exec::exec_events::TurnStartedEvent; use codex_exec::exec_events::Usage; use codex_exec::exec_events::WebSearchItem; use codex_protocol::ThreadId; +use codex_protocol::config_types::ModeKind; use codex_protocol::models::WebSearchAction; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; @@ -117,6 +118,7 @@ fn task_started_produces_turn_started_event() { "t1", EventMsg::TurnStarted(codex_core::protocol::TurnStartedEvent { model_context_window: Some(32_000), + collaboration_mode_kind: ModeKind::Custom, }), )); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index c96bc60706..1dbdbc767b 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -252,6 +252,9 @@ async fn run_codex_tool_session_inner( .await; continue; } + EventMsg::PlanDelta(_) => { + continue; + } EventMsg::Error(err_event) => { // Always respond in tools/call's expected shape, and include conversationId so the client can resume. let result = create_call_tool_result_with_thread_id( diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 86d39d6d4a..db2964c40c 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -166,10 +166,13 @@ pub enum AltScreenMode { } /// Initial collaboration mode to use when the TUI starts. -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema, TS)] +#[derive( + Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema, TS, Default, +)] #[serde(rename_all = "snake_case")] pub enum ModeKind { Plan, + #[default] Code, PairProgramming, Execute, diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index e7c916a61d..9a387a9d22 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -20,6 +20,7 @@ use ts_rs::TS; pub enum TurnItem { UserMessage(UserMessageItem), AgentMessage(AgentMessageItem), + Plan(PlanItem), Reasoning(ReasoningItem), WebSearch(WebSearchItem), ContextCompaction(ContextCompactionItem), @@ -44,6 +45,12 @@ pub struct AgentMessageItem { pub content: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] +pub struct PlanItem { + pub id: String, + pub text: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct ReasoningItem { pub id: String, @@ -218,6 +225,7 @@ impl TurnItem { match self { TurnItem::UserMessage(item) => item.id.clone(), TurnItem::AgentMessage(item) => item.id.clone(), + TurnItem::Plan(item) => item.id.clone(), TurnItem::Reasoning(item) => item.id.clone(), TurnItem::WebSearch(item) => item.id.clone(), TurnItem::ContextCompaction(item) => item.id.clone(), @@ -228,6 +236,7 @@ impl TurnItem { match self { TurnItem::UserMessage(item) => vec![item.as_legacy_event()], TurnItem::AgentMessage(item) => item.as_legacy_events(), + TurnItem::Plan(_) => Vec::new(), TurnItem::WebSearch(item) => vec![item.as_legacy_event()], TurnItem::Reasoning(item) => item.as_legacy_events(show_raw_agent_reasoning), TurnItem::ContextCompaction(item) => vec![item.as_legacy_event()], diff --git a/codex-rs/protocol/src/plan_tool.rs b/codex-rs/protocol/src/plan_tool.rs index a9038eb03b..affb4c1896 100644 --- a/codex-rs/protocol/src/plan_tool.rs +++ b/codex-rs/protocol/src/plan_tool.rs @@ -22,6 +22,7 @@ pub struct PlanItemArg { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct UpdatePlanArgs { + /// Arguments for the `update_plan` todo/checklist tool (not plan mode). #[serde(default)] pub explanation: Option, pub plan: Vec, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index fbaf4f4457..929d7d73c7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -14,6 +14,7 @@ use std::time::Duration; use crate::ThreadId; use crate::approvals::ElicitationRequestEvent; use crate::config_types::CollaborationMode; +use crate::config_types::ModeKind; use crate::config_types::Personality; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::config_types::WindowsSandboxLevel; @@ -838,6 +839,7 @@ pub enum EventMsg { ItemCompleted(ItemCompletedEvent), AgentMessageContentDelta(AgentMessageContentDeltaEvent), + PlanDelta(PlanDeltaEvent), ReasoningContentDelta(ReasoningContentDeltaEvent), ReasoningRawContentDelta(ReasoningRawContentDeltaEvent), @@ -1017,6 +1019,14 @@ impl HasLegacyEvent for AgentMessageContentDeltaEvent { } } +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] +pub struct PlanDeltaEvent { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct ReasoningContentDeltaEvent { pub thread_id: String, @@ -1107,6 +1117,8 @@ pub struct TurnCompleteEvent { pub struct TurnStartedEvent { // TODO(aibrahim): make this not optional pub model_context_window: Option, + #[serde(default)] + pub collaboration_mode_kind: ModeKind, } #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 491ea3add3..7793f0a39c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -196,6 +196,7 @@ mod skills; use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; +use crate::streaming::controller::PlanStreamController; use crate::streaming::controller::StreamController; use chrono::Local; @@ -485,6 +486,8 @@ pub(crate) struct ChatWidget { rate_limit_poller: Option>, // Stream lifecycle controller stream_controller: Option, + // Stream lifecycle controller for proposed plan output. + plan_stream_controller: Option, running_commands: HashMap, suppressed_exec_calls: HashSet, skills_all: Vec, @@ -553,6 +556,12 @@ pub(crate) struct ChatWidget { had_work_activity: bool, // Whether the current turn emitted a plan update. saw_plan_update_this_turn: bool, + // Whether the current turn emitted a proposed plan item. + saw_plan_item_this_turn: bool, + // Incremental buffer for streamed plan content. + plan_delta_buffer: String, + // True while a plan item is streaming. + plan_item_active: bool, // Status-indicator elapsed seconds captured at the last emitted final-message separator. // // This lets the separator show per-chunk work time (since the previous separator) rather than @@ -896,7 +905,7 @@ impl ChatWidget { fn on_agent_message(&mut self, message: String) { // If we have a stream_controller, then the final agent message is redundant and will be a // duplicate of what has already been streamed. - if self.stream_controller.is_none() { + if self.stream_controller.is_none() && !message.is_empty() { self.handle_streaming_delta(message); } self.flush_answer_stream_with_separator(); @@ -908,6 +917,56 @@ impl ChatWidget { self.handle_streaming_delta(delta); } + fn on_plan_delta(&mut self, delta: String) { + if self.active_mode_kind() != ModeKind::Plan { + return; + } + if !self.plan_item_active { + self.plan_item_active = true; + self.plan_delta_buffer.clear(); + } + self.plan_delta_buffer.push_str(&delta); + // Before streaming plan content, flush any active exec cell group. + self.flush_unified_exec_wait_streak(); + self.flush_active_cell(); + + if self.plan_stream_controller.is_none() { + self.plan_stream_controller = Some(PlanStreamController::new( + self.last_rendered_width.get().map(|w| w.saturating_sub(4)), + )); + } + if let Some(controller) = self.plan_stream_controller.as_mut() + && controller.push(&delta) + { + self.app_event_tx.send(AppEvent::StartCommitAnimation); + } + self.request_redraw(); + } + + fn on_plan_item_completed(&mut self, text: String) { + let streamed_plan = self.plan_delta_buffer.trim().to_string(); + let plan_text = if text.trim().is_empty() { + streamed_plan + } else { + text + }; + self.plan_delta_buffer.clear(); + self.plan_item_active = false; + self.saw_plan_item_this_turn = true; + if let Some(mut controller) = self.plan_stream_controller.take() + && let Some(cell) = controller.finalize() + { + self.add_boxed_history(cell); + // TODO: Replace streamed output with the final plan item text if plan streaming is + // removed or if we need to reconcile mismatches between streamed and final content. + return; + } + if plan_text.is_empty() { + return; + } + self.add_to_history(history_cell::new_proposed_plan(plan_text)); + } + fn on_agent_reasoning_delta(&mut self, delta: String) { // For reasoning deltas, do not stream to history. Accumulate the // current reasoning block and extract the first bold element @@ -954,6 +1013,10 @@ impl ChatWidget { fn on_task_started(&mut self) { self.agent_turn_running = true; self.saw_plan_update_this_turn = false; + self.saw_plan_item_this_turn = false; + self.plan_delta_buffer.clear(); + self.plan_item_active = false; + self.plan_stream_controller = None; self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; @@ -969,6 +1032,11 @@ impl ChatWidget { fn on_task_complete(&mut self, last_agent_message: Option, from_replay: bool) { // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); + if let Some(mut controller) = self.plan_stream_controller.take() + && let Some(cell) = controller.finalize() + { + self.add_boxed_history(cell); + } self.flush_unified_exec_wait_streak(); // Mark task stopped and request redraw now that all content is in history. self.agent_turn_running = false; @@ -981,7 +1049,7 @@ impl ChatWidget { self.request_redraw(); if !from_replay && self.queued_user_messages.is_empty() { - self.maybe_prompt_plan_implementation(last_agent_message.as_deref()); + self.maybe_prompt_plan_implementation(); } // If there is a queued user message, send exactly one now to begin the next turn. self.maybe_send_next_queued_input(); @@ -993,7 +1061,7 @@ impl ChatWidget { self.maybe_show_pending_rate_limit_prompt(); } - fn maybe_prompt_plan_implementation(&mut self, last_agent_message: Option<&str>) { + fn maybe_prompt_plan_implementation(&mut self) { if !self.collaboration_modes_enabled() { return; } @@ -1003,8 +1071,7 @@ impl ChatWidget { if self.active_mode_kind() != ModeKind::Plan { return; } - let has_message = last_agent_message.is_some_and(|message| !message.trim().is_empty()); - if !has_message && !self.saw_plan_update_this_turn { + if !self.saw_plan_item_this_turn { return; } if !self.bottom_pane.no_modal_or_popup_active() { @@ -1749,15 +1816,28 @@ impl ChatWidget { /// Periodic tick to commit at most one queued line to history with a small delay, /// animating the output. pub(crate) fn on_commit_tick(&mut self) { + let mut has_controller = false; + let mut all_idle = true; if let Some(controller) = self.stream_controller.as_mut() { + has_controller = true; let (cell, is_idle) = controller.on_commit_tick(); if let Some(cell) = cell { self.bottom_pane.hide_status_indicator(); self.add_boxed_history(cell); } - if is_idle { - self.app_event_tx.send(AppEvent::StopCommitAnimation); + all_idle &= is_idle; + } + if let Some(controller) = self.plan_stream_controller.as_mut() { + has_controller = true; + let (cell, is_idle) = controller.on_commit_tick(); + if let Some(cell) = cell { + self.bottom_pane.hide_status_indicator(); + self.add_boxed_history(cell); } + all_idle &= is_idle; + } + if has_controller && all_idle { + self.app_event_tx.send(AppEvent::StopCommitAnimation); } } @@ -2160,6 +2240,7 @@ impl ChatWidget { rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), rate_limit_poller: None, stream_controller: None, + plan_stream_controller: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, @@ -2188,6 +2269,9 @@ impl ChatWidget { needs_final_message_separator: false, had_work_activity: false, saw_plan_update_this_turn: false, + saw_plan_item_this_turn: false, + plan_delta_buffer: String::new(), + plan_item_active: false, last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback, @@ -2301,6 +2385,7 @@ impl ChatWidget { rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), rate_limit_poller: None, stream_controller: None, + plan_stream_controller: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, @@ -2319,6 +2404,9 @@ impl ChatWidget { thread_name: None, forked_from: None, saw_plan_update_this_turn: false, + saw_plan_item_this_turn: false, + plan_delta_buffer: String::new(), + plan_item_active: false, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -2431,6 +2519,7 @@ impl ChatWidget { rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), rate_limit_poller: None, stream_controller: None, + plan_stream_controller: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, @@ -2459,6 +2548,9 @@ impl ChatWidget { needs_final_message_separator: false, had_work_activity: false, saw_plan_update_this_turn: false, + saw_plan_item_this_turn: false, + plan_delta_buffer: String::new(), + plan_item_active: false, last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback, @@ -3219,6 +3311,7 @@ impl ChatWidget { match msg { EventMsg::AgentMessageDelta(_) + | EventMsg::PlanDelta(_) | EventMsg::AgentReasoningDelta(_) | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) => {} @@ -3234,6 +3327,7 @@ impl ChatWidget { EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) } + EventMsg::PlanDelta(event) => self.on_plan_delta(event.delta), EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta, @@ -3357,11 +3451,15 @@ impl ChatWidget { EventMsg::ThreadRolledBack(_) => {} EventMsg::RawResponseItem(_) | EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) | EventMsg::DynamicToolCallRequest(_) => {} + EventMsg::ItemCompleted(event) => { + if let codex_protocol::items::TurnItem::Plan(plan_item) = event.item { + self.on_plan_item_completed(plan_item.text); + } + } } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 331afdf90a..d24504578c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -809,6 +809,7 @@ async fn make_chatwidget_manual( rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), rate_limit_poller: None, stream_controller: None, + plan_stream_controller: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), skills_all: Vec::new(), @@ -840,6 +841,9 @@ async fn make_chatwidget_manual( needs_final_message_separator: false, had_work_activity: false, saw_plan_update_this_turn: false, + saw_plan_item_this_turn: false, + plan_delta_buffer: String::new(), + plan_item_active: false, last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback: codex_feedback::CodexFeedback::new(), @@ -1277,7 +1281,7 @@ async fn plan_implementation_popup_skips_when_messages_queued() { } #[tokio::test] -async fn plan_implementation_popup_shows_on_plan_update_without_message() { +async fn plan_implementation_popup_skips_without_proposed_plan() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); let plan_mask = @@ -1295,10 +1299,31 @@ async fn plan_implementation_popup_shows_on_plan_update_without_message() { }); chat.on_task_complete(None, false); + let popup = render_bottom_popup(&chat, 80); + assert!( + !popup.contains(PLAN_IMPLEMENTATION_TITLE), + "expected no plan popup without proposed plan output, got {popup:?}" + ); +} + +#[tokio::test] +async fn plan_implementation_popup_shows_after_proposed_plan_output() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + + chat.on_task_started(); + chat.on_plan_delta("- Step 1\n- Step 2\n".to_string()); + chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string()); + chat.on_task_complete(None, false); + let popup = render_bottom_popup(&chat, 80); assert!( popup.contains(PLAN_IMPLEMENTATION_TITLE), - "expected plan popup after plan update, got {popup:?}" + "expected plan popup after proposed plan output, got {popup:?}" ); } @@ -1957,6 +1982,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); @@ -1991,6 +2017,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); @@ -2779,6 +2806,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); @@ -3793,6 +3821,7 @@ async fn interrupt_clears_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); @@ -3866,6 +3895,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); chat.handle_codex_event(Event { @@ -3897,6 +3927,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); // Provide a deterministic header for the status line. @@ -3949,6 +3980,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); // Provide a deterministic header via a bold reasoning chunk. @@ -3998,6 +4030,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); @@ -4554,6 +4587,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); drain_insert_history(&mut rx); @@ -4591,6 +4625,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); @@ -4785,6 +4820,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); chat.handle_codex_event(Event { @@ -4832,6 +4868,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); // Build a vt100 visual from the history insertions only (no UI overlay) @@ -4921,6 +4958,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { model_context_window: None, + collaboration_mode_kind: ModeKind::Custom, }), }); for i in 0..30 { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 1f19c929a2..aadfb318f8 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -25,6 +25,7 @@ use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; use crate::render::renderable::Renderable; +use crate::style::proposed_plan_style; use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; @@ -1768,6 +1769,63 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell { PlanUpdateCell { explanation, plan } } +pub(crate) fn new_proposed_plan(plan_markdown: String) -> ProposedPlanCell { + ProposedPlanCell { plan_markdown } +} + +pub(crate) fn new_proposed_plan_stream( + lines: Vec>, + is_stream_continuation: bool, +) -> ProposedPlanStreamCell { + ProposedPlanStreamCell { + lines, + is_stream_continuation, + } +} + +#[derive(Debug)] +pub(crate) struct ProposedPlanCell { + plan_markdown: String, +} + +#[derive(Debug)] +pub(crate) struct ProposedPlanStreamCell { + lines: Vec>, + is_stream_continuation: bool, +} + +impl HistoryCell for ProposedPlanCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into()); + lines.push(Line::from(" ")); + + let mut plan_lines: Vec> = vec![Line::from(" ")]; + let plan_style = proposed_plan_style(); + let wrap_width = width.saturating_sub(4).max(1) as usize; + let mut body: Vec> = Vec::new(); + append_markdown(&self.plan_markdown, Some(wrap_width), &mut body); + if body.is_empty() { + body.push(Line::from("(empty)".dim().italic())); + } + plan_lines.extend(prefix_lines(body, " ".into(), " ".into())); + plan_lines.push(Line::from(" ")); + + lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style))); + lines + } +} + +impl HistoryCell for ProposedPlanStreamCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } + + fn is_stream_continuation(&self) -> bool { + self.is_stream_continuation + } +} + #[derive(Debug)] pub(crate) struct PlanUpdateCell { explanation: Option, diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index e73ebd41e6..462962e980 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -1,5 +1,8 @@ use crate::history_cell::HistoryCell; use crate::history_cell::{self}; +use crate::render::line_utils::prefix_lines; +use crate::style::proposed_plan_style; +use ratatui::prelude::Stylize; use ratatui::text::Line; use super::StreamState; @@ -80,6 +83,106 @@ impl StreamController { } } +/// Controller that streams proposed plan markdown into a styled plan block. +pub(crate) struct PlanStreamController { + state: StreamState, + header_emitted: bool, + top_padding_emitted: bool, +} + +impl PlanStreamController { + pub(crate) fn new(width: Option) -> Self { + Self { + state: StreamState::new(width), + header_emitted: false, + top_padding_emitted: false, + } + } + + /// Push a delta; if it contains a newline, commit completed lines and start animation. + pub(crate) fn push(&mut self, delta: &str) -> bool { + let state = &mut self.state; + if !delta.is_empty() { + state.has_seen_delta = true; + } + state.collector.push_delta(delta); + if delta.contains('\n') { + let newly_completed = state.collector.commit_complete_lines(); + if !newly_completed.is_empty() { + state.enqueue(newly_completed); + return true; + } + } + false + } + + /// Finalize the active stream. Drain and emit now. + pub(crate) fn finalize(&mut self) -> Option> { + let remaining = { + let state = &mut self.state; + state.collector.finalize_and_drain() + }; + let mut out_lines = Vec::new(); + { + let state = &mut self.state; + if !remaining.is_empty() { + state.enqueue(remaining); + } + let step = state.drain_all(); + out_lines.extend(step); + } + + self.state.clear(); + self.emit(out_lines, true) + } + + /// Step animation: commit at most one queued line and handle end-of-drain cleanup. + pub(crate) fn on_commit_tick(&mut self) -> (Option>, bool) { + let step = self.state.step(); + (self.emit(step, false), self.state.is_idle()) + } + + fn emit( + &mut self, + lines: Vec>, + include_bottom_padding: bool, + ) -> Option> { + if lines.is_empty() && !include_bottom_padding { + return None; + } + + let mut out_lines: Vec> = Vec::new(); + let is_stream_continuation = self.header_emitted; + if !self.header_emitted { + out_lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into()); + out_lines.push(Line::from(" ")); + self.header_emitted = true; + } + + let mut plan_lines: Vec> = Vec::new(); + if !self.top_padding_emitted { + plan_lines.push(Line::from(" ")); + self.top_padding_emitted = true; + } + plan_lines.extend(lines); + if include_bottom_padding { + plan_lines.push(Line::from(" ")); + } + + let plan_style = proposed_plan_style(); + let plan_lines = prefix_lines(plan_lines, " ".into(), " ".into()) + .into_iter() + .map(|line| line.style(plan_style)) + .collect::>(); + out_lines.extend(plan_lines); + + Some(Box::new(history_cell::new_proposed_plan_stream( + out_lines, + is_stream_continuation, + ))) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs index 26a71bd897..2b4ab4c4d3 100644 --- a/codex-rs/tui/src/style.rs +++ b/codex-rs/tui/src/style.rs @@ -9,6 +9,10 @@ pub fn user_message_style() -> Style { user_message_style_for(default_bg()) } +pub fn proposed_plan_style() -> Style { + proposed_plan_style_for(default_bg()) +} + /// Returns the style for a user-authored message using the provided terminal background. pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { match terminal_bg { @@ -17,6 +21,13 @@ pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { } } +pub fn proposed_plan_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { + match terminal_bg { + Some(bg) => Style::default().bg(proposed_plan_bg(bg)), + None => Style::default(), + } +} + #[allow(clippy::disallowed_methods)] pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color { let (top, alpha) = if is_light(terminal_bg) { @@ -26,3 +37,13 @@ pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color { }; best_color(blend(top, terminal_bg, alpha)) } + +#[allow(clippy::disallowed_methods)] +pub fn proposed_plan_bg(terminal_bg: (u8, u8, u8)) -> Color { + let (top, alpha) = if is_light(terminal_bg) { + ((0, 110, 150), 0.08) + } else { + ((80, 170, 220), 0.2) + }; + best_color(blend(top, terminal_bg, alpha)) +} From 2d10aa68598591bf6e4eb2261cfe534852dd8d9a Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Fri, 30 Jan 2026 11:15:21 -0800 Subject: [PATCH 67/70] Tui: hide Code mode footer label (#10063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Title Hide Code mode footer label/cycle hint; add Plan footer-collapse snapshots Summary - Keep Code mode internal naming but suppress the footer mode label + cycle hint when Code is active. - Only show the cycle hint when a non‑Code mode indicator is present. - Add Plan-mode footer collapse snapshot coverage (empty + queued, across widths) and update existing footer collapse snapshots for the new Code behavior. Notes - The test run currently fails in codex-cloud-requirements on origin/main due to a stale auth.mode field; no fix is included in this PR to keep the diff minimal. Codex author `codex resume 019c0296-cfd4-7193-9b0a-6949048e4546` --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 118 ++++++++++++++++-- codex-rs/tui/src/bottom_pane/footer.rs | 9 +- ...er__tests__footer_collapse_empty_full.snap | 2 +- ...ollapse_empty_mode_cycle_with_context.snap | 2 +- ...apse_empty_mode_cycle_without_context.snap | 2 +- ...ests__footer_collapse_empty_mode_only.snap | 2 +- ...ests__footer_collapse_plan_empty_full.snap | 13 ++ ...se_plan_empty_mode_cycle_with_context.snap | 13 ++ ...plan_empty_mode_cycle_without_context.snap | 13 ++ ..._footer_collapse_plan_empty_mode_only.snap | 13 ++ ...ests__footer_collapse_plan_queue_full.snap | 13 ++ ...se_plan_queue_message_without_context.snap | 13 ++ ..._footer_collapse_plan_queue_mode_only.snap | 13 ++ ...ollapse_plan_queue_short_with_context.snap | 13 ++ ...apse_plan_queue_short_without_context.snap | 13 ++ ...er__tests__footer_collapse_queue_full.snap | 2 +- ...ollapse_queue_message_without_context.snap | 2 +- ...ests__footer_collapse_queue_mode_only.snap | 2 +- ...ter_collapse_queue_short_with_context.snap | 2 +- ..._collapse_queue_short_without_context.snap | 2 +- codex-rs/tui/src/chatwidget.rs | 2 +- 21 files changed, 237 insertions(+), 27 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_full.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_with_context.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_without_context.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_only.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_full.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_message_without_context.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_mode_only.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_with_context.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_without_context.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 7c8b7585ac..1d3cf942fb 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2873,7 +2873,8 @@ impl ChatComposer { } ActivePopup::None => { let footer_props = self.footer_props(); - let show_cycle_hint = !footer_props.is_task_running; + let show_cycle_hint = + !footer_props.is_task_running && self.collaboration_mode_indicator.is_some(); let show_shortcuts_hint = match footer_props.mode { FooterMode::ComposerEmpty => !self.is_in_paste_burst(), FooterMode::QuitShortcutReminder @@ -3353,23 +3354,23 @@ mod tests { fn setup_collab_footer( composer: &mut ChatComposer, context_percent: i64, - indicator: CollaborationModeIndicator, + indicator: Option, ) { composer.set_collaboration_modes_enabled(true); - composer.set_collaboration_mode_indicator(Some(indicator)); + composer.set_collaboration_mode_indicator(indicator); composer.set_context_window(Some(context_percent), None); } - // Empty textarea, agent idle: shortcuts hint can show, and cycle hint is available. + // Empty textarea, agent idle: shortcuts hint can show, and cycle hint is hidden. snapshot_composer_state_with_width("footer_collapse_empty_full", 120, true, |composer| { - setup_collab_footer(composer, 100, CollaborationModeIndicator::Code); + setup_collab_footer(composer, 100, None); }); snapshot_composer_state_with_width( "footer_collapse_empty_mode_cycle_with_context", 60, true, |composer| { - setup_collab_footer(composer, 100, CollaborationModeIndicator::Code); + setup_collab_footer(composer, 100, None); }, ); snapshot_composer_state_with_width( @@ -3377,7 +3378,7 @@ mod tests { 44, true, |composer| { - setup_collab_footer(composer, 100, CollaborationModeIndicator::Code); + setup_collab_footer(composer, 100, None); }, ); snapshot_composer_state_with_width( @@ -3385,13 +3386,47 @@ mod tests { 26, true, |composer| { - setup_collab_footer(composer, 100, CollaborationModeIndicator::Code); + setup_collab_footer(composer, 100, None); + }, + ); + + // Empty textarea, plan mode idle: shortcuts hint and cycle hint are available. + snapshot_composer_state_with_width( + "footer_collapse_plan_empty_full", + 120, + true, + |composer| { + setup_collab_footer(composer, 100, Some(CollaborationModeIndicator::Plan)); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_empty_mode_cycle_with_context", + 60, + true, + |composer| { + setup_collab_footer(composer, 100, Some(CollaborationModeIndicator::Plan)); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_empty_mode_cycle_without_context", + 44, + true, + |composer| { + setup_collab_footer(composer, 100, Some(CollaborationModeIndicator::Plan)); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_empty_mode_only", + 26, + true, + |composer| { + setup_collab_footer(composer, 100, Some(CollaborationModeIndicator::Plan)); }, ); // Textarea has content, agent running, steer enabled: queue hint is shown. snapshot_composer_state_with_width("footer_collapse_queue_full", 120, true, |composer| { - setup_collab_footer(composer, 98, CollaborationModeIndicator::Code); + setup_collab_footer(composer, 98, None); composer.set_steer_enabled(true); composer.set_task_running(true); composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); @@ -3401,7 +3436,7 @@ mod tests { 50, true, |composer| { - setup_collab_footer(composer, 98, CollaborationModeIndicator::Code); + setup_collab_footer(composer, 98, None); composer.set_steer_enabled(true); composer.set_task_running(true); composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); @@ -3412,7 +3447,7 @@ mod tests { 40, true, |composer| { - setup_collab_footer(composer, 98, CollaborationModeIndicator::Code); + setup_collab_footer(composer, 98, None); composer.set_steer_enabled(true); composer.set_task_running(true); composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); @@ -3423,7 +3458,7 @@ mod tests { 30, true, |composer| { - setup_collab_footer(composer, 98, CollaborationModeIndicator::Code); + setup_collab_footer(composer, 98, None); composer.set_steer_enabled(true); composer.set_task_running(true); composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); @@ -3434,7 +3469,64 @@ mod tests { 20, true, |composer| { - setup_collab_footer(composer, 98, CollaborationModeIndicator::Code); + setup_collab_footer(composer, 98, None); + composer.set_steer_enabled(true); + composer.set_task_running(true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + + // Textarea has content, plan mode active, agent running, steer enabled: queue hint + mode. + snapshot_composer_state_with_width( + "footer_collapse_plan_queue_full", + 120, + true, + |composer| { + setup_collab_footer(composer, 98, Some(CollaborationModeIndicator::Plan)); + composer.set_steer_enabled(true); + composer.set_task_running(true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_queue_short_with_context", + 50, + true, + |composer| { + setup_collab_footer(composer, 98, Some(CollaborationModeIndicator::Plan)); + composer.set_steer_enabled(true); + composer.set_task_running(true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_queue_message_without_context", + 40, + true, + |composer| { + setup_collab_footer(composer, 98, Some(CollaborationModeIndicator::Plan)); + composer.set_steer_enabled(true); + composer.set_task_running(true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_queue_short_without_context", + 30, + true, + |composer| { + setup_collab_footer(composer, 98, Some(CollaborationModeIndicator::Plan)); + composer.set_steer_enabled(true); + composer.set_task_running(true); + composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); + }, + ); + snapshot_composer_state_with_width( + "footer_collapse_plan_queue_mode_only", + 20, + true, + |composer| { + setup_collab_footer(composer, 98, Some(CollaborationModeIndicator::Plan)); composer.set_steer_enabled(true); composer.set_task_running(true); composer.set_text_content("Test".to_string(), Vec::new(), Vec::new()); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 1ded8ce319..c297b9e362 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -73,12 +73,12 @@ pub(crate) struct FooterProps { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum CollaborationModeIndicator { Plan, - Code, PairProgramming, Execute, } const MODE_CYCLE_HINT: &str = "shift+tab to cycle"; +const FOOTER_CONTEXT_GAP_COLS: u16 = 1; impl CollaborationModeIndicator { fn label(self, show_cycle_hint: bool) -> String { @@ -89,7 +89,6 @@ impl CollaborationModeIndicator { }; match self { CollaborationModeIndicator::Plan => format!("Plan mode{suffix}"), - CollaborationModeIndicator::Code => format!("Code mode{suffix}"), CollaborationModeIndicator::PairProgramming => { format!("Pair Programming mode{suffix}") } @@ -101,7 +100,6 @@ impl CollaborationModeIndicator { let label = self.label(show_cycle_hint); match self { CollaborationModeIndicator::Plan => Span::from(label).magenta(), - CollaborationModeIndicator::Code => Span::from(label).dim(), CollaborationModeIndicator::PairProgramming => Span::from(label).cyan(), CollaborationModeIndicator::Execute => Span::from(label).dim(), } @@ -475,7 +473,10 @@ pub(crate) fn can_show_left_with_context(area: Rect, left_width: u16, context_wi let Some(context_x) = right_aligned_x(area, context_width) else { return true; }; - let left_extent = FOOTER_INDENT_COLS as u16 + left_width; + if left_width == 0 { + return true; + } + let left_extent = FOOTER_INDENT_COLS as u16 + left_width + FOOTER_CONTEXT_GAP_COLS; left_extent <= context_x.saturating_sub(area.x) } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_full.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_full.snap index 90a7b12e4a..4324d806e2 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_full.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_full.snap @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts · Code mode (shift+tab to cycle) 100% context left " +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_with_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_with_context.snap index cfc93be1e6..ecaeb58149 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_with_context.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_with_context.snap @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" Code mode (shift+tab to cycle) 100% context left " +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_without_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_without_context.snap index 9901a8fbbf..118ac25291 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_without_context.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_without_context.snap @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" Code mode (shift+tab to cycle) " +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_only.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_only.snap index 98c7c33889..9c95004785 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_only.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_only.snap @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" Code mode " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_full.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_full.snap new file mode 100644 index 0000000000..f39aefad64 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_full.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ? for shortcuts · Plan mode (shift+tab to cycle) 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_with_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_with_context.snap new file mode 100644 index 0000000000..347ba31648 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_with_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" Plan mode (shift+tab to cycle) 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_without_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_without_context.snap new file mode 100644 index 0000000000..006e2a1773 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_without_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" Plan mode (shift+tab to cycle) " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_only.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_only.snap new file mode 100644 index 0000000000..bea268c57e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_only.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anythin " +" " +" " +" " +" " +" " +" " +" Plan mode " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_full.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_full.snap new file mode 100644 index 0000000000..5f0f353822 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_full.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue message · Plan mode 98% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_message_without_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_message_without_context.snap new file mode 100644 index 0000000000..017e3eb2aa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_message_without_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue message · Plan mode " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_mode_only.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_mode_only.snap new file mode 100644 index 0000000000..35a94ac73a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_mode_only.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" Plan mode " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_with_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_with_context.snap new file mode 100644 index 0000000000..77f38dc4e7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_with_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue · Plan mode 98% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_without_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_without_context.snap new file mode 100644 index 0000000000..91f917e987 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_without_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue · Plan mode " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_full.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_full.snap index cfb47fa9d7..1057803326 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_full.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_full.snap @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" tab to queue message · Code mode 98% context left " +" tab to queue message 98% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_message_without_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_message_without_context.snap index 0a91488b58..4f44c0424e 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_message_without_context.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_message_without_context.snap @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" tab to queue message · Code mode " +" tab to queue 98% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_mode_only.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_mode_only.snap index 9adcc6e059..e2d1d2e282 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_mode_only.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_mode_only.snap @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" Code mode " +" tab to queue " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_with_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_with_context.snap index 0910602616..b7128fd415 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_with_context.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_with_context.snap @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" tab to queue · Code mode 98% context left " +" tab to queue message 98% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_without_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_without_context.snap index 993dc34035..3df7f74328 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_without_context.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_without_context.snap @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" tab to queue · Code mode " +" tab to queue message " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7793f0a39c..b0c8c7cf33 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5313,7 +5313,7 @@ impl ChatWidget { } match self.active_mode_kind() { ModeKind::Plan => Some(CollaborationModeIndicator::Plan), - ModeKind::Code => Some(CollaborationModeIndicator::Code), + ModeKind::Code => None, ModeKind::PairProgramming => Some(CollaborationModeIndicator::PairProgramming), ModeKind::Execute => Some(CollaborationModeIndicator::Execute), ModeKind::Custom => None, From e6d913af2dc5ace19c06d5ae6a35c34cff996a1c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 30 Jan 2026 11:18:39 -0800 Subject: [PATCH 68/70] chore: rename ChatGpt -> Chatgpt in type names (#10244) When using ChatGPT in names of types, we should be consistent, so this renames some types with `ChatGpt` in the name to `Chatgpt`. From https://rust-lang.github.io/api-guidelines/naming.html: > In `UpperCamelCase`, acronyms and contractions of compound words count as one word: use `Uuid` rather than `UUID`, `Usize` rather than `USize` or `Stdin` rather than `StdIn`. In `snake_case`, acronyms and contractions are lower-cased: `is_xid_start`. This PR updates existing uses of `ChatGpt` and changes them to `Chatgpt`. Though in all cases where it could affect the wire format, I visually inspected that we don't change anything there. That said, this _will_ change the codegen because it will affect the spelling of type names. For example, this renames `AuthMode::ChatGPT` to `AuthMode::Chatgpt` in `app-server-protocol`, but the wire format is still `"chatgpt"`. This PR also updates a number of types in `codex-rs/core/src/auth.rs`. --- .../src/protocol/common.rs | 2 +- .../app-server/src/codex_message_processor.rs | 2 +- .../app-server/tests/common/auth_fixtures.rs | 2 +- codex-rs/cli/src/login.rs | 2 +- codex-rs/core/src/auth.rs | 66 +++++++++---------- codex-rs/core/src/auth/storage.rs | 2 +- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/model_provider_info.rs | 2 +- codex-rs/core/src/models_manager/manager.rs | 2 +- codex-rs/core/tests/responses_headers.rs | 4 +- codex-rs/core/tests/suite/auth_refresh.rs | 18 ++--- codex-rs/login/src/server.rs | 2 +- codex-rs/tui/src/onboarding/auth.rs | 2 +- codex-rs/tui/src/status/helpers.rs | 2 +- 14 files changed, 55 insertions(+), 55 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index efcc651b33..eb59480b58 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -30,7 +30,7 @@ pub enum AuthMode { /// OpenAI API key provided by the caller and stored by Codex. ApiKey, /// ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex). - ChatGPT, + Chatgpt, /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. /// /// ChatGPT auth tokens are supplied by an external host app and are only diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c02f3c7368..78f670c049 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1246,7 +1246,7 @@ impl CodexMessageProcessor { let account = match self.auth_manager.auth_cached() { Some(auth) => Some(match auth { CodexAuth::ApiKey(_) => Account::ApiKey {}, - CodexAuth::ChatGpt(_) | CodexAuth::ChatGptAuthTokens(_) => { + CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { let email = auth.get_account_email(); let plan_type = auth.account_plan_type(); diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index b78d5b105d..e689e41838 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -159,7 +159,7 @@ pub fn write_chatgpt_auth( let last_refresh = fixture.last_refresh.unwrap_or_else(|| Some(Utc::now())); let auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), + auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, tokens: Some(tokens), last_refresh, diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index cee002f339..01a830acb2 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -236,7 +236,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { std::process::exit(1); } }, - AuthMode::ChatGPT => { + AuthMode::Chatgpt => { eprintln!("Logged in using ChatGPT"); std::process::exit(0); } diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 4bddbe3031..fb4114a7c2 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -44,15 +44,15 @@ use thiserror::Error; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AuthMode { ApiKey, - ChatGPT, + Chatgpt, } /// Authentication mechanism used by the current user. #[derive(Debug, Clone)] pub enum CodexAuth { ApiKey(ApiKeyAuth), - ChatGpt(ChatGptAuth), - ChatGptAuthTokens(ChatGptAuthTokens), + Chatgpt(ChatgptAuth), + ChatgptAuthTokens(ChatgptAuthTokens), } #[derive(Debug, Clone)] @@ -61,18 +61,18 @@ pub struct ApiKeyAuth { } #[derive(Debug, Clone)] -pub struct ChatGptAuth { - state: ChatGptAuthState, +pub struct ChatgptAuth { + state: ChatgptAuthState, storage: Arc, } #[derive(Debug, Clone)] -pub struct ChatGptAuthTokens { - state: ChatGptAuthState, +pub struct ChatgptAuthTokens { + state: ChatgptAuthState, } #[derive(Debug, Clone)] -struct ChatGptAuthState { +struct ChatgptAuthState { auth_dot_json: Arc>>, client: CodexHttpClient, } @@ -161,18 +161,18 @@ impl CodexAuth { } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); - let state = ChatGptAuthState { + let state = ChatgptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), client, }; match auth_mode { - ApiAuthMode::ChatGPT => { + ApiAuthMode::Chatgpt => { let storage = create_auth_storage(codex_home.to_path_buf(), storage_mode); - Ok(Self::ChatGpt(ChatGptAuth { state, storage })) + Ok(Self::Chatgpt(ChatgptAuth { state, storage })) } ApiAuthMode::ChatgptAuthTokens => { - Ok(Self::ChatGptAuthTokens(ChatGptAuthTokens { state })) + Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state })) } ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"), } @@ -189,31 +189,31 @@ impl CodexAuth { pub fn internal_auth_mode(&self) -> AuthMode { match self { Self::ApiKey(_) => AuthMode::ApiKey, - Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => AuthMode::ChatGPT, + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt, } } pub fn api_auth_mode(&self) -> ApiAuthMode { match self { Self::ApiKey(_) => ApiAuthMode::ApiKey, - Self::ChatGpt(_) => ApiAuthMode::ChatGPT, - Self::ChatGptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens, + Self::Chatgpt(_) => ApiAuthMode::Chatgpt, + Self::ChatgptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens, } } pub fn is_chatgpt_auth(&self) -> bool { - self.internal_auth_mode() == AuthMode::ChatGPT + self.internal_auth_mode() == AuthMode::Chatgpt } pub fn is_external_chatgpt_tokens(&self) -> bool { - matches!(self, Self::ChatGptAuthTokens(_)) + matches!(self, Self::ChatgptAuthTokens(_)) } /// Returns `None` is `is_internal_auth_mode() != AuthMode::ApiKey`. pub fn api_key(&self) -> Option<&str> { match self { Self::ApiKey(auth) => Some(auth.api_key.as_str()), - Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => None, + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => None, } } @@ -234,7 +234,7 @@ impl CodexAuth { pub fn get_token(&self) -> Result { match self { Self::ApiKey(auth) => Ok(auth.api_key.clone()), - Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => { + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => { let access_token = self.get_token_data()?.access_token; Ok(access_token) } @@ -278,8 +278,8 @@ impl CodexAuth { /// Returns `None` if `is_chatgpt_auth()` is false. fn get_current_auth_json(&self) -> Option { let state = match self { - Self::ChatGpt(auth) => &auth.state, - Self::ChatGptAuthTokens(auth) => &auth.state, + Self::Chatgpt(auth) => &auth.state, + Self::ChatgptAuthTokens(auth) => &auth.state, Self::ApiKey(_) => return None, }; #[expect(clippy::unwrap_used)] @@ -294,7 +294,7 @@ impl CodexAuth { /// Consider this private to integration tests. pub fn create_dummy_chatgpt_auth_for_testing() -> Self { let auth_dot_json = AuthDotJson { - auth_mode: Some(ApiAuthMode::ChatGPT), + auth_mode: Some(ApiAuthMode::Chatgpt), openai_api_key: None, tokens: Some(TokenData { id_token: Default::default(), @@ -306,12 +306,12 @@ impl CodexAuth { }; let client = crate::default_client::create_client(); - let state = ChatGptAuthState { + let state = ChatgptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), client, }; let storage = create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File); - Self::ChatGpt(ChatGptAuth { state, storage }) + Self::Chatgpt(ChatgptAuth { state, storage }) } fn from_api_key_with_client(api_key: &str, _client: CodexHttpClient) -> Self { @@ -325,7 +325,7 @@ impl CodexAuth { } } -impl ChatGptAuth { +impl ChatgptAuth { fn current_auth_json(&self) -> Option { #[expect(clippy::unwrap_used)] self.state.auth_dot_json.lock().unwrap().clone() @@ -436,8 +436,8 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { if let Some(required_method) = config.forced_login_method { let method_violation = match (required_method, auth.internal_auth_mode()) { (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, - (ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT) => None, - (ForcedLoginMethod::Api, AuthMode::ChatGPT) => Some( + (ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt) => None, + (ForcedLoginMethod::Api, AuthMode::Chatgpt) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." .to_string(), ), @@ -750,7 +750,7 @@ impl AuthDotJson { if self.openai_api_key.is_some() { return ApiAuthMode::ApiKey; } - ApiAuthMode::ChatGPT + ApiAuthMode::Chatgpt } fn storage_mode( @@ -1127,11 +1127,11 @@ impl AuthManager { None => return Ok(()), }; match auth { - CodexAuth::ChatGptAuthTokens(_) => { + CodexAuth::ChatgptAuthTokens(_) => { self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) .await } - CodexAuth::ChatGpt(chatgpt_auth) => { + CodexAuth::Chatgpt(chatgpt_auth) => { let token_data = chatgpt_auth.current_token_data().ok_or_else(|| { RefreshTokenError::Transient(std::io::Error::other( "Token data is not available.", @@ -1170,7 +1170,7 @@ impl AuthManager { async fn refresh_if_stale(&self, auth: &CodexAuth) -> Result { let chatgpt_auth = match auth { - CodexAuth::ChatGpt(chatgpt_auth) => chatgpt_auth, + CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth, _ => return Ok(false), }; @@ -1250,7 +1250,7 @@ impl AuthManager { async fn refresh_tokens( &self, - auth: &ChatGptAuth, + auth: &ChatgptAuth, refresh_token: String, ) -> Result<(), RefreshTokenError> { let refresh_response = try_refresh_token(refresh_token, auth.client()).await?; @@ -1373,7 +1373,7 @@ mod tests { .unwrap() .unwrap(); assert_eq!(None, auth.api_key()); - assert_eq!(AuthMode::ChatGPT, auth.internal_auth_mode()); + assert_eq!(AuthMode::Chatgpt, auth.internal_auth_mode()); let auth_dot_json = auth .get_current_auth_json() diff --git a/codex-rs/core/src/auth/storage.rs b/codex-rs/core/src/auth/storage.rs index b4f4bb8e7e..1ac1b2ee18 100644 --- a/codex-rs/core/src/auth/storage.rs +++ b/codex-rs/core/src/auth/storage.rs @@ -565,7 +565,7 @@ mod tests { let auth_file = get_auth_file(codex_home.path()); std::fs::write(&auth_file, "stale")?; let auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), + auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, tokens: Some(TokenData { id_token: Default::default(), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 30dd8d24fc..5a1ce90dd9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5271,7 +5271,7 @@ mod tests { model_info.slug.as_str(), None, Some("test@test.com".to_string()), - Some(AuthMode::ChatGPT), + Some(AuthMode::Chatgpt), false, "test".to_string(), session_source, diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 0f5a8643a7..56ff76d7f6 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -137,7 +137,7 @@ impl ModelProviderInfo { &self, auth_mode: Option, ) -> crate::error::Result { - let default_base_url = if matches!(auth_mode, Some(AuthMode::ChatGPT)) { + let default_base_url = if matches!(auth_mode, Some(AuthMode::Chatgpt)) { "https://chatgpt.com/backend-api/codex" } else { "https://api.openai.com/v1" diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 40f9c8ccdc..936e40d1bc 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -274,7 +274,7 @@ impl ModelsManager { let mut merged_presets = ModelPreset::merge(remote_presets, existing_presets); let chatgpt_mode = matches!( self.auth_manager.get_internal_auth_mode(), - Some(AuthMode::ChatGPT) + Some(AuthMode::Chatgpt) ); merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode); diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index b2c64818cc..86d6e8ce61 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -71,7 +71,7 @@ async fn responses_stream_includes_subagent_header_on_review() { let config = Arc::new(config); let conversation_id = ThreadId::new(); - let auth_mode = AuthMode::ChatGPT; + let auth_mode = AuthMode::Chatgpt; let session_source = SessionSource::SubAgent(SubAgentSource::Review); let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let otel_manager = OtelManager::new( @@ -169,7 +169,7 @@ async fn responses_stream_includes_subagent_header_on_other() { let config = Arc::new(config); let conversation_id = ThreadId::new(); - let auth_mode = AuthMode::ChatGPT; + let auth_mode = AuthMode::Chatgpt; let session_source = SessionSource::SubAgent(SubAgentSource::Other("my-task".to_string())); let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); diff --git a/codex-rs/core/tests/suite/auth_refresh.rs b/codex-rs/core/tests/suite/auth_refresh.rs index 4ef3b82eec..a6be08f23c 100644 --- a/codex-rs/core/tests/suite/auth_refresh.rs +++ b/codex-rs/core/tests/suite/auth_refresh.rs @@ -51,7 +51,7 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { let initial_last_refresh = Utc::now() - Duration::days(1); let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); let initial_auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), + auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), @@ -113,7 +113,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> { let initial_last_refresh = Utc::now() - Duration::days(1); let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); let initial_auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), + auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), @@ -159,7 +159,7 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> { 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), + auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(stale_refresh), @@ -218,7 +218,7 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re let initial_last_refresh = Utc::now() - Duration::days(1); let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); let initial_auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), + auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), @@ -268,7 +268,7 @@ async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> let initial_last_refresh = Utc::now() - Duration::days(1); let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); let initial_auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), + auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), @@ -320,7 +320,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { let initial_last_refresh = Utc::now() - Duration::days(1); let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); let initial_auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), + auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), @@ -329,7 +329,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token"); let disk_auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), + auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, tokens: Some(disk_tokens.clone()), last_refresh: Some(initial_last_refresh), @@ -412,7 +412,7 @@ async fn unauthorized_recovery_skips_reload_on_account_mismatch() -> Result<()> let initial_last_refresh = Utc::now() - Duration::days(1); let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); let initial_auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), + auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), @@ -427,7 +427,7 @@ async fn unauthorized_recovery_skips_reload_on_account_mismatch() -> Result<()> ..disk_tokens.clone() }; let disk_auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), + auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 5272c84eaa..e571d9b824 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -560,7 +560,7 @@ pub(crate) async fn persist_tokens_async( tokens.account_id = Some(acc.to_string()); } let auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), + auth_mode: Some(AuthMode::Chatgpt), openai_api_key: api_key, tokens: Some(tokens), last_refresh: Some(Utc::now()), diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index daf64f36d8..7378b6628e 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -663,7 +663,7 @@ impl AuthModeWidget { fn handle_existing_chatgpt_login(&mut self) -> bool { if matches!( self.login_status, - LoginStatus::AuthMode(AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) + LoginStatus::AuthMode(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens) ) { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; self.request_frame.schedule_frame(); diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index 0a801227f5..bc4cbb9c16 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -91,7 +91,7 @@ pub(crate) fn compose_account_display( let auth = auth_manager.auth_cached()?; match auth { - CodexAuth::ChatGpt(_) | CodexAuth::ChatGptAuthTokens(_) => { + CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { let email = auth.get_account_email(); let plan = plan .map(|plan_type| title_case(format!("{plan_type:?}").as_str())) From 9b29a48a09893cfdea7d7ea056918a9a011ccc01 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 30 Jan 2026 11:48:03 -0800 Subject: [PATCH 69/70] Plan mode prompt (#10238) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- .../core/templates/collaboration_mode/plan.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 395b6c368b..2ae72aba43 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -60,18 +60,14 @@ Do not ask questions that can be answered from the repo or system (for example, Every assistant turn MUST be exactly one of: A) a `request_user_input` tool call (questions/options only), OR -B) a non-final status update with no questions and no plan content, OR C) the final output: a titled, plan-only document. Rules: * No questions in free text (only via `request_user_input`). * Never mix a `request_user_input` call with plan content. -* Status updates must not include questions or plan content. * Internal tool/repo exploration is allowed privately before A, B, or C. -Status updates should be frequent during exploration. Provide 1-2 sentence updates that summarize discoveries, assumption changes, or why you are changing direction. Use Parallel tools for exploration. - ## Ask a lot, but never ask trivia You SHOULD ask many questions, but each question must: @@ -113,19 +109,15 @@ When you present the official plan, wrap it in a `` block so the Example: -# Plan title -- Step 1 -- Step 2 +plan content -The final plan must be plan-only and include: +plan content should be human and agent digestible. The final plan must be plan-only and include: * A clear title -* A TL;DR section (3–5 bullets) -* Exact file paths to change -* Exact structures or shapes to introduce or modify -* Exact function, method, type, and variable names and signatures -* Test cases +* tldr section. don't necessary call it tldr. +* Important changes or additions of signatures, structs, types. +* Test cases and scenarios * Explicit assumptions and defaults chosen where needed Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a `` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. From 2457bb3c404e4c092eef52ff9b2538b01623a17d Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Fri, 30 Jan 2026 11:57:13 -0800 Subject: [PATCH 70/70] Fix deploy (#10251) Fix https://github.com/openai/codex/actions/runs/21527697445/job/62035898666 --- codex-rs/tui/src/app.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c65ce4498d..7ed9d0b035 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1154,6 +1154,7 @@ impl App { return Ok(AppExitInfo { token_usage: app.token_usage(), thread_id: app.chat_widget.thread_id(), + thread_name: app.chat_widget.thread_name(), update_action: app.pending_update_action, exit_reason, });