diff --git a/.bazelrc b/.bazelrc index e39a3aff22..c8b8682c4b 100644 --- a/.bazelrc +++ b/.bazelrc @@ -105,9 +105,9 @@ common:ci --disk_cache= # Shared config for the main Bazel CI workflow. common:ci-bazel --config=ci common:ci-bazel --build_metadata=TAG_workflow=bazel -# Bazel CI cross-compiles in several legs, and the V8-backed code-mode tests -# are not stable in that setup yet. Keep running the rest of the Rust -# integration suites through the workspace-root launcher. +# Keep code-mode integration cases out of ordinary Bazel legs. The +# Windows-cross config below re-enables them after generating its Windows V8 +# snapshot on the Windows runner. common:ci-bazel --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode:: # Shared config for Bazel-backed Rust linting. @@ -186,12 +186,16 @@ common:ci-windows-cross --config=ci-windows common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true common:ci-windows-cross --host_platform=//:rbe common:ci-windows-cross --strategy=TestRunner=local +# V8 embeds IsolateData offsets in snapshot builtins; Windows snapshots must be +# generated by a Windows mksnapshot binary rather than the Linux RBE host tool. +common:ci-windows-cross --strategy=V8Mksnapshot=local common:ci-windows-cross --local_test_jobs=4 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 # Native Windows CI still covers the PowerShell tests. The cross-built gnullvm # binaries currently hang in PowerShell AST parser tests when those binaries are -# run on the Windows runner. -common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::,powershell +# run on the Windows runner. Keep V8-backed code-mode tests enabled except for +# the hidden dynamic-tool callback test, which currently times out on Windows. +common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=powershell,suite::code_mode::code_mode_can_call_hidden_dynamic_tools common:ci-windows-cross --platforms=//:windows_x86_64_gnullvm common:ci-windows-cross --extra_execution_platforms=//:rbe,//:windows_x86_64_msvc common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host_toolchain diff --git a/.codex/skills/pushing-ci-changes/SKILL.md b/.codex/skills/pushing-ci-changes/SKILL.md new file mode 100644 index 0000000000..cb91bdd981 --- /dev/null +++ b/.codex/skills/pushing-ci-changes/SKILL.md @@ -0,0 +1,17 @@ +--- +name: pushing-ci-changes +description: Pushing GitHub Actions changes, resolving push rejection, requesting upload exceptions. +--- + +The Codex repository prevents anyone from uploading changes to its CI configuration unless they've +been granted a temporary role. + +In order to push changes to `.github/**/*.yml` and related files you'll need the user to read +go/workflow-approvals and request an approval through that flow. It is not possible for you to +make progress by requesting an exemption yourself. + +If you know you're about to push changes that would fail due to these restrictions you should still +try to push anyways to confirm the user's account doesn't already have the necessary approval. + +If you encounter a failed push due to these restrictions, share the go/workflow-approvals link with +the user and ask them to return control to you once the approval has propagated to GitHub. diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index c6dfd60231..bddbb5adc9 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -20,8 +20,9 @@ jobs: test: # PRs use the sharded Windows cross-compiled test jobs below. Post-merge # pushes to main also run the native Windows test job for broader Windows - # signal without putting PR latency back on the critical path. Cargo CI - # owns V8/code-mode test coverage for now. + # signal without putting PR latency back on the critical path. When + # authenticated RBE is available, the Windows-cross shards exercise the + # source-built V8/code-mode targets. timeout-minutes: 30 strategy: fail-fast: false @@ -91,9 +92,9 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all - # V8-backed code-mode tests are covered by Cargo CI. Bazel CI - # cross-compiles in several legs, and those tests are not stable in - # that setup yet. + # Keep V8-backed code-mode tests out of the ordinary macOS/Linux + # legs; authenticated Windows-cross shards below exercise the + # source-built gnullvm V8 path. -//codex-rs/code-mode:code-mode-unit-tests -//codex-rs/v8-poc:v8-poc-unit-tests ) @@ -135,9 +136,9 @@ jobs: key: ${{ steps.prepare_bazel.outputs.repository-cache-key }} test-windows-shard: - # Split the Windows Bazel test leg across separate Windows - # hosts. Each shard still uses Linux RBE for build actions, but the test - # execution itself happens on its own Windows runner. + # Split the Windows Bazel test leg across separate Windows hosts. Jobs with + # BuildBuddy credentials use Linux RBE for build actions; test execution + # remains on a Windows runner. timeout-minutes: 30 strategy: fail-fast: false @@ -183,7 +184,7 @@ jobs: run: | set -euo pipefail - bazel_test_query='tests(//...) except tests(//third_party/v8:all) except //codex-rs/code-mode:code-mode-unit-tests except //codex-rs/v8-poc:v8-poc-unit-tests except attr(tags, "manual", tests(//...))' + bazel_test_query='tests(//...) except tests(//third_party/v8:all) except attr(tags, "manual", tests(//...))' mapfile -t bazel_targets < <( MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \ | LC_ALL=C sort @@ -289,9 +290,8 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all - # Keep this aligned with the main Bazel job. The native Windows - # job preserves broad post-merge coverage, but code-mode/V8 tests - # are covered by Cargo CI rather than Bazel for now. + # Keep this job broad and cheap; authenticated Windows-cross jobs + # add source-built V8-backed code-mode coverage. -//codex-rs/code-mode:code-mode-unit-tests -//codex-rs/v8-poc:v8-poc-unit-tests ) diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 60d508b21d..50ecf2f8d6 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -167,8 +167,6 @@ jobs: USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }} CARGO_INCREMENTAL: "0" SCCACHE_CACHE_SIZE: 10G - # In rust-ci, representative release-profile checks use thin LTO for faster feedback. - CARGO_PROFILE_RELEASE_LTO: ${{ matrix.profile == 'release' && 'thin' || 'fat' }} strategy: fail-fast: false diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 89bcd33ea1..14d38b9db5 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -2,23 +2,6 @@ name: rust-release-windows on: workflow_call: - inputs: - release-lto: - required: true - type: string - secrets: - AZURE_TRUSTED_SIGNING_CLIENT_ID: - required: true - AZURE_TRUSTED_SIGNING_TENANT_ID: - required: true - AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID: - required: true - AZURE_TRUSTED_SIGNING_ENDPOINT: - required: true - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: - required: true - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: - required: true # Cargo's libgit2 transport has been flaky when fetching git dependencies with # nested submodules. Prefer the system git CLI across every Cargo invocation. @@ -29,17 +12,14 @@ jobs: build-windows-binaries: name: Build Windows binaries - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on }} - # Windows release builds can exceed an hour on fat-LTO mainline releases, - # so keep the timeout aligned with the top-level release build headroom. + # Windows release builds can exceed an hour, so keep the timeout aligned + # with the top-level release build headroom. timeout-minutes: 90 permissions: contents: read defaults: run: working-directory: codex-rs - env: - CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }} - strategy: fail-fast: false matrix: @@ -150,6 +130,9 @@ jobs: - build-windows-binaries name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runs_on }} + environment: + name: azure-artifact-signing + deployment: false timeout-minutes: 90 permissions: contents: read @@ -211,12 +194,12 @@ jobs: with: target: ${{ matrix.target }} binaries: ${{ env.WINDOWS_BINARIES }} - client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} - endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + client-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} - name: Stage artifacts shell: bash diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 5e3b263ed0..03a0cc57ff 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -149,9 +149,6 @@ jobs: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} # Use the git CLI instead of Cargo's libgit2 path for git dependencies. # macOS release runners have intermittently failed to fetch nested # submodules through SecureTransport/libgit2, especially libwebrtc's @@ -325,7 +322,6 @@ jobs: for binary in ${{ matrix.binaries }}; do build_args+=(--bin "$binary") done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" cargo build --target "$target" --release --timings "${build_args[@]}" - name: Upload Cargo timings @@ -1330,8 +1326,6 @@ jobs: if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }} needs: tag-check uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} secrets: inherit argument-comment-lint-release-assets: @@ -1952,6 +1946,9 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + environment: + name: mainline-release-winget + deployment: false steps: - name: Publish to WinGet diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f5e8b56230..1e79865d43 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3052,6 +3052,7 @@ dependencies = [ "codex-model-provider-info", "codex-protocol", "codex-tools", + "codex-utils-absolute-path", "http 1.4.0", "pretty_assertions", "schemars 0.8.22", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 15e0e2c010..5d6d116049 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -501,7 +501,7 @@ debug = "none" strip = "symbols" [profile.release] -lto = "fat" +lto = "thin" split-debuginfo = "off" # Because we bundle some of these executables with the TypeScript CLI, we # remove everything to make the binary as small as possible. diff --git a/codex-rs/README.md b/codex-rs/README.md index 18bffd9f64..8c2ae94040 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -1,99 +1,3 @@ -# Codex CLI (Rust Implementation) +# Codex CLI -We provide Codex CLI as a standalone executable to ensure a zero-dependency install. - -## Installing Codex - -Today, the easiest way to install Codex is via `npm`: - -```shell -npm i -g @openai/codex -codex -``` - -You can also install via Homebrew (`brew install --cask codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). - -## Documentation quickstart - -- First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management). -- Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md). - -## What's new in the Rust CLI - -The Rust implementation is now the maintained Codex CLI and serves as the default experience. It includes a number of features that the legacy TypeScript CLI never supported. - -### Config - -Codex supports a rich set of configuration options. Note that the Rust CLI uses `config.toml` instead of `config.json`. See [`docs/config.md`](../docs/config.md) for details. - -### Model Context Protocol Support - -#### MCP client - -Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#connecting-to-mcp-servers) for details. - -#### MCP server (experimental) - -Codex can be launched as an MCP _server_ by running `codex mcp-server`. This allows _other_ MCP clients to use Codex as a tool for another agent. - -Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out: - -```shell -npx @modelcontextprotocol/inspector codex mcp-server -``` - -Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.toml`, and `codex mcp-server` to run the MCP server directly. - -### Notifications - -You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9. - -### `codex exec` to run Codex programmatically/non-interactively - -To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. If you provide both a prompt argument and piped stdin, Codex appends stdin as a `` block after the prompt so patterns like `echo "my output" | codex exec "Summarize this concisely"` work naturally. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on. -Use `codex exec --ephemeral ...` to run without persisting session rollout files to disk. - -### Experimenting with the Codex Sandbox - -To test to see what happens when a command is run under the sandbox provided by Codex, use the `sandbox` subcommand in Codex CLI: - -``` -# Uses the sandbox implementation for the current host OS: -# Seatbelt on macOS, the Linux sandbox on Linux, and Windows restricted token on Windows. -codex sandbox [COMMAND]... - -# macOS-only diagnostic option -codex sandbox --log-denials [COMMAND]... -``` - -`codex sandbox` also accepts `--profile NAME` (`-p NAME`) to layer -`$CODEX_HOME/NAME.config.toml` onto the base user config for the sandboxed -command. - -### Selecting a sandbox policy via `--sandbox` - -The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option: - -```shell -# Run Codex with the default, read-only sandbox -codex --sandbox read-only - -# Allow the agent to write within the current workspace while still blocking network access -codex --sandbox workspace-write - -# Danger! Disable sandboxing entirely (only do this if you are already running in a container or other isolated env) -codex --sandbox danger-full-access -``` - -In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval. - -## Code Organization - -This folder is the root of a Cargo workspace. It contains quite a bit of experimental code, but here are the key crates: - -- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this becomes a library crate that is generally useful for building other Rust/native applications that use Codex. -- [`exec/`](./exec) "headless" CLI for use in automation. -- [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/). -- [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands. - -If you want to contribute or inspect behavior in detail, start by reading the module-level `README.md` files under each crate and run the project workspace from the top-level `codex-rs` directory so shared config, features, and build scripts stay aligned. +[**Codex CLI Documentation**](https://developers.openai.com/codex/cli) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 59e3f50467..7177952c37 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1347,6 +1347,7 @@ fn thread_initialized_event_serializes_expected_shape() { initialization_mode: ThreadInitializationMode::New, subagent_source: None, parent_thread_id: None, + forked_from_thread_id: None, created_at: 1, }, }); @@ -1379,6 +1380,7 @@ fn thread_initialized_event_serializes_expected_shape() { "initialization_mode": "new", "subagent_source": null, "parent_thread_id": null, + "forked_from_thread_id": null, "created_at": 1 } }) @@ -2429,6 +2431,7 @@ fn subagent_thread_started_review_serializes_expected_shape() { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2461,18 +2464,26 @@ fn subagent_thread_started_review_serializes_expected_shape() { assert_eq!(payload["event_params"]["initialization_mode"], "new"); assert_eq!(payload["event_params"]["subagent_source"], "review"); assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!( + payload["event_params"]["forked_from_thread_id"], + json!(null) + ); } #[test] -fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { +fn subagent_thread_started_thread_spawn_serializes_thread_lineage() { let parent_thread_id = codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") .expect("valid thread id"); + let forked_from_thread_id = + codex_protocol::ThreadId::from_string("22222222-2222-4222-8222-222222222222") + .expect("valid thread id"); let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { session_id: "session-root".to_string(), thread_id: "thread-spawn".to_string(), parent_thread_id: Some(parent_thread_id.to_string()), + forked_from_thread_id: Some(forked_from_thread_id.to_string()), product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2497,6 +2508,10 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { payload["event_params"]["parent_thread_id"], "11111111-1111-1111-1111-111111111111" ); + assert_eq!( + payload["event_params"]["forked_from_thread_id"], + "22222222-2222-4222-8222-222222222222" + ); assert_eq!(payload["event_params"]["session_id"], "session-root"); } @@ -2507,6 +2522,7 @@ fn subagent_thread_started_memory_consolidation_serializes_expected_shape() { session_id: "session-root".to_string(), thread_id: "thread-memory".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2533,6 +2549,7 @@ fn subagent_thread_started_other_serializes_expected_shape() { session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2558,6 +2575,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: Some(parent_thread_id.to_string()), + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2588,6 +2606,7 @@ async fn subagent_thread_started_publishes_without_initialize() { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2662,6 +2681,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: Some(parent_thread_id.to_string()), + forked_from_thread_id: None, product_client_id: "parent-client".to_string(), client_name: "parent-client".to_string(), client_version: "1.0.0".to_string(), @@ -2732,6 +2752,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() { session_id: "session-root".to_string(), thread_id: "thread-subagent".to_string(), parent_thread_id: Some("thread-1".to_string()), + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 5367e651e4..bdc2c996dc 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -157,6 +157,7 @@ pub(crate) struct ThreadInitializedEventParams { pub(crate) initialization_mode: ThreadInitializationMode, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, + pub(crate) forked_from_thread_id: Option, pub(crate) created_at: u64, } @@ -1060,6 +1061,7 @@ pub(crate) fn subagent_thread_started_event_request( initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), parent_thread_id: input.parent_thread_id, + forked_from_thread_id: input.forked_from_thread_id, created_at: input.created_at, }; ThreadInitializedEvent { diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b056108507..1dff5de89d 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -348,6 +348,7 @@ pub struct SubAgentThreadStartedInput { pub session_id: String, pub thread_id: String, pub parent_thread_id: Option, + pub forked_from_thread_id: Option, pub product_client_id: String, pub client_name: String, pub client_version: String, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5b41178530..877000928f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -1268,6 +1268,7 @@ impl AnalyticsReducer { let session_id = thread.session_id; let thread_id = thread.id; let parent_thread_id = thread.parent_thread_id; + let forked_from_thread_id = thread.forked_from_id; let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -1299,6 +1300,7 @@ impl AnalyticsReducer { initialization_mode, subagent_source: thread_metadata.subagent_source.clone(), parent_thread_id: thread_metadata.parent_thread_id, + forked_from_thread_id, created_at: u64::try_from(thread.created_at).unwrap_or_default(), }, }, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index dc5dd6ad60..861ae1dcbd 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1971,15 +1971,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningItemContent": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index faf5a96879..16656e3a55 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2777,15 +2777,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6aa4c5f35e..4802a52300 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -3786,8 +3786,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -6278,6 +6278,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "AppToolApproval": { "enum": [ "auto", @@ -12168,6 +12231,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/v2/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/v2/AppSummary" @@ -12216,6 +12285,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", @@ -13525,15 +13595,8 @@ "type": "object" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningEffortOption": { @@ -19239,4 +19302,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6d859ee5f3..2b9e30bae7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -643,6 +643,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "AppToolApproval": { "enum": [ "auto", @@ -8690,6 +8753,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/AppSummary" @@ -8738,6 +8807,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", @@ -10047,15 +10117,8 @@ "type": "object" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningEffortOption": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 09cf897898..e311090f2c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -667,15 +667,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 8ba25f9a07..4b1d1dddbf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -462,15 +462,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 70fc43f7b8..67e493a4da 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -462,15 +462,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index 32ec2e7679..ce6c976d34 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -185,15 +185,8 @@ "type": "object" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningEffortOption": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index c9fe43c34f..5a4c2e5b09 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -37,6 +37,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "HookEventName": { "enum": [ "preToolUse", @@ -78,6 +141,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/AppSummary" @@ -126,6 +195,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index a644ce8c4e..0e3e704bf2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -606,15 +606,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 0d3a3fe8db..0dbb7ef004 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -718,15 +718,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SandboxPolicy": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index a500c21587..903553afaa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 6e08edb962..93f44759bf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index eed031d1e4..a78d1837c8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 3a9e51f2a7..288c3e6c8e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -718,15 +718,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SandboxPolicy": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 9bc687fbfb..2ef7959a8b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index 42a76ae364..fbcaee3ee8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -124,15 +124,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 8640c4e9ed..1a182e5d69 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -718,15 +718,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SandboxPolicy": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index f7830e221e..66ed134c83 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 9a634c1110..d331fa9bf7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 6c64eb3275..dd5ebbf6ee 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -606,15 +606,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index f997976aca..070944a28b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -154,15 +154,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 5fe9754576..cc4bce10ff 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -606,15 +606,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 1db1497218..10b43d4354 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -606,15 +606,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/typescript/ReasoningEffort.ts b/codex-rs/app-server-protocol/schema/typescript/ReasoningEffort.ts index c0798f43a3..d40f5bd657 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ReasoningEffort.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ReasoningEffort.ts @@ -5,4 +5,4 @@ /** * See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning */ -export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; +export type ReasoningEffort = string; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts new file mode 100644 index 0000000000..dd5f76229f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason"; + +export type AppTemplateSummary = { templateId: string, name: string, description: string | null, canonicalConnectorId: string | null, logoUrl: string | null, logoUrlDark: string | null, materializedAppIds: Array, reason: AppTemplateUnavailableReason | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts new file mode 100644 index 0000000000..563056798e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppTemplateUnavailableReason = "NOT_CONFIGURED_FOR_WORKSPACE" | "NO_ACTIVE_WORKSPACE"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts index 64836c87f7..cc2042dd5d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts @@ -3,8 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { AppSummary } from "./AppSummary"; +import type { AppTemplateSummary } from "./AppTemplateSummary"; import type { PluginHookSummary } from "./PluginHookSummary"; import type { PluginSummary } from "./PluginSummary"; import type { SkillSummary } from "./SkillSummary"; -export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, mcpServers: Array, }; +export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, appTemplates: Array, mcpServers: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 48e1f9580b..b4c65946d5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -21,6 +21,8 @@ export type { AppMetadata } from "./AppMetadata"; export type { AppReview } from "./AppReview"; export type { AppScreenshot } from "./AppScreenshot"; export type { AppSummary } from "./AppSummary"; +export type { AppTemplateSummary } from "./AppTemplateSummary"; +export type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason"; export type { AppToolApproval } from "./AppToolApproval"; export type { AppToolsConfig } from "./AppToolsConfig"; export type { ApprovalsReviewer } from "./ApprovalsReviewer"; 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 aa65f9ab9d..fdc2af15d3 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -621,7 +621,7 @@ impl ThreadHistoryBuilder { receiver_thread_ids: Vec::new(), prompt: Some(payload.prompt.clone()), model: Some(payload.model.clone()), - reasoning_effort: Some(payload.reasoning_effort), + reasoning_effort: Some(payload.reasoning_effort.clone()), agents_states: HashMap::new(), }; self.upsert_item_in_current_turn(item); @@ -656,7 +656,7 @@ impl ThreadHistoryBuilder { receiver_thread_ids, prompt: Some(payload.prompt.clone()), model: Some(payload.model.clone()), - reasoning_effort: Some(payload.reasoning_effort), + reasoning_effort: Some(payload.reasoning_effort.clone()), agents_states, }); } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 1b7e8ba8ff..6222eb6e73 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -642,9 +642,32 @@ pub struct PluginDetail { pub skills: Vec, pub hooks: Vec, pub apps: Vec, + pub app_templates: Vec, pub mcp_servers: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[ts(export_to = "v2/")] +pub enum AppTemplateUnavailableReason { + NotConfiguredForWorkspace, + NoActiveWorkspace, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppTemplateSummary { + pub template_id: String, + pub name: String, + pub description: Option, + pub canonical_connector_id: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub materialized_app_ids: Vec, + pub reason: 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 dc02ff8c04..6d705afa98 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -188,11 +188,11 @@ Example with notification opt-out: - `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`. - `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`. - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. -- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. +- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 18c276f07d..eca927fc23 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -316,9 +316,8 @@ impl ExternalAgentConfigService { Some(self.external_agent_home.as_path()), mcp_settings.as_ref(), )?; - let mcp_server_names = migrated_mcp_server_names(&migrated_mcp); + let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp); if !is_empty_toml_table(&migrated_mcp) { - let mut should_include = true; if target_config.exists() { let existing_raw = fs::read_to_string(&target_config)?; let mut existing = if existing_raw.trim().is_empty() { @@ -328,10 +327,10 @@ impl ExternalAgentConfigService { invalid_data_error(format!("invalid existing config.toml: {err}")) })? }; - should_include = merge_missing_toml_values(&mut existing, &migrated_mcp)?; + mcp_server_names = merge_missing_mcp_servers(&mut existing, &migrated_mcp)?; } - if should_include { + if !mcp_server_names.is_empty() { items.push(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, description: format!( @@ -341,7 +340,7 @@ impl ExternalAgentConfigService { ), cwd: cwd.clone(), details: Some(MigrationDetails { - mcp_servers: named_migrations(mcp_server_names.clone()), + mcp_servers: named_migrations(mcp_server_names), ..Default::default() }), }); @@ -863,7 +862,7 @@ impl ExternalAgentConfigService { toml::from_str::(&existing_raw) .map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))? }; - if merge_missing_toml_values(&mut existing, &migrated)? { + if !merge_missing_mcp_servers(&mut existing, &migrated)?.is_empty() { write_toml_file(&target_config, &existing)?; } Ok(()) @@ -1538,6 +1537,43 @@ fn merge_missing_toml_values(existing: &mut TomlValue, incoming: &TomlValue) -> } } +fn merge_missing_mcp_servers( + existing: &mut TomlValue, + incoming: &TomlValue, +) -> io::Result> { + let existing_root = existing + .as_table_mut() + .ok_or_else(|| invalid_data_error("expected existing config to be a TOML table"))?; + let incoming_root = incoming + .as_table() + .ok_or_else(|| invalid_data_error("expected migrated MCP config to be a TOML table"))?; + let Some(incoming_servers) = incoming_root.get("mcp_servers") else { + return Ok(Vec::new()); + }; + let incoming_servers = incoming_servers + .as_table() + .ok_or_else(|| invalid_data_error("expected migrated MCP servers to be a TOML table"))?; + let Some(existing_servers) = existing_root.get_mut("mcp_servers") else { + existing_root.insert( + "mcp_servers".to_string(), + TomlValue::Table(incoming_servers.clone()), + ); + return Ok(incoming_servers.keys().cloned().collect()); + }; + let Some(existing_servers) = existing_servers.as_table_mut() else { + return Ok(Vec::new()); + }; + + let mut merged_server_names = Vec::new(); + for (server_name, incoming_server) in incoming_servers { + if !existing_servers.contains_key(server_name) { + existing_servers.insert(server_name.clone(), incoming_server.clone()); + merged_server_names.push(server_name.clone()); + } + } + Ok(merged_server_names) +} + fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> { let serialized = toml::to_string_pretty(value) .map_err(|err| invalid_data_error(format!("failed to serialize config.toml: {err}")))?; diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index 47f93a5f8b..8c120fb51b 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -638,6 +638,124 @@ Research with Codex carefully.""" assert_eq!(agent, expected_agent); } +#[tokio::test] +async fn import_repo_mcp_preserves_existing_same_named_server() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::write( + repo_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "mixedTransport": { + "command": "mcp-remote-proxy", + "args": [ + "https://example.com/mixed-transport", + "--transport", + "http" + ], + "url": "https://example.com/mixed-transport" + } + } + }"#, + ) + .expect("write mcp"); + fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir"); + let existing_config = r#"[mcp_servers.mixedTransport] +url = "https://example.com/mixed-transport" +"#; + fs::write( + repo_root.join(".codex").join("config.toml"), + existing_config, + ) + .expect("write config"); + + let service = service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ); + assert_eq!( + service + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"), + Vec::::new() + ); + + service + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }]) + .await + .expect("import"); + + assert_eq!( + fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), + existing_config + ); +} + +#[tokio::test] +async fn detect_repo_mcp_lists_only_missing_servers() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::write( + repo_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "docs": {"command": "docs-server"}, + "mixedTransport": {"command": "mcp-remote-proxy"} + } + }"#, + ) + .expect("write mcp"); + fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir"); + fs::write( + repo_root.join(".codex").join("config.toml"), + r#"[mcp_servers.mixedTransport] +url = "https://example.com/mixed-transport" +"#, + ) + .expect("write config"); + + let items = service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: format!( + "Migrate MCP servers from {} into {}", + repo_root.display(), + repo_root.join(".codex").join("config.toml").display() + ), + cwd: Some(repo_root), + details: Some(MigrationDetails { + mcp_servers: vec![NamedMigration { + name: "docs".to_string(), + }], + ..Default::default() + }), + }] + ); +} + #[tokio::test] async fn import_home_migrates_supported_config_fields_skills_and_agents_md() { let (_root, external_agent_home, codex_home) = fixture_paths(); diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 84f22809ee..d89f0b0645 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -455,14 +455,13 @@ impl MessageProcessor { .plugins_manager() .maybe_start_plugin_startup_tasks_for_config( &config.plugins_config_input(), - auth_manager.clone(), + auth_manager, Some(on_effective_plugins_changed), ); } let config_processor = ConfigRequestProcessor::new( outgoing.clone(), config_manager.clone(), - auth_manager, thread_manager.clone(), analytics_events_client, ); diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index e53f13df84..7d460d5a97 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -62,10 +62,10 @@ fn reasoning_efforts_from_preset( efforts: Vec, ) -> Vec { efforts - .iter() + .into_iter() .map(|preset| ReasoningEffortOption { reasoning_effort: preset.effort, - description: preset.description.to_string(), + description: preset.description, }) .collect() } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 2f3c87d868..0e94528a7a 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -30,6 +30,8 @@ use codex_app_server_protocol::AdditionalContextKind; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::AppTemplateSummary; +use codex_app_server_protocol::AppTemplateUnavailableReason; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AskForApproval; diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 3571026193..3753550925 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -7,7 +7,6 @@ use crate::error_code::invalid_request; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; use codex_analytics::AnalyticsEventsClient; -use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::ComputerUseRequirements; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -29,9 +28,7 @@ use codex_app_server_protocol::NetworkDomainPermission; use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::NetworkUnixSocketPermission; use codex_app_server_protocol::SandboxMode; -use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::WindowsSandboxSetupMode; -use codex_chatgpt::connectors; use codex_config::ConfigRequirementsToml; use codex_config::HookEventsToml; use codex_config::HookHandlerConfig as CoreHookHandlerConfig; @@ -42,7 +39,6 @@ use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement; use codex_core::ThreadManager; use codex_features::canonical_feature_for_key; use codex_features::feature_for_key; -use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_plugin::PluginId; use codex_protocol::config_types::WebSearchMode; @@ -50,21 +46,18 @@ use serde_json::json; use std::path::PathBuf; const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ - "apps", + "auth_elicitation", "memories", "mentions_v2", - "plugins", "remote_control", "remote_plugin", "tool_suggest", - "tool_call_mcp_elicitation", ]; #[derive(Clone)] pub(crate) struct ConfigRequestProcessor { outgoing: Arc, config_manager: ConfigManager, - auth_manager: Arc, thread_manager: Arc, analytics_events_client: AnalyticsEventsClient, } @@ -73,14 +66,12 @@ impl ConfigRequestProcessor { pub(crate) fn new( outgoing: Arc, config_manager: ConfigManager, - auth_manager: Arc, thread_manager: Arc, analytics_events_client: AnalyticsEventsClient, ) -> Self { Self { outgoing, config_manager, - auth_manager, thread_manager, analytics_events_client, } @@ -151,7 +142,6 @@ impl ConfigRequestProcessor { request_id: ConnectionRequestId, params: ExperimentalFeatureEnablementSetParams, ) -> Result, JSONRPCErrorError> { - let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true); let response = self .handle_config_mutation_result(self.set_experimental_feature_enablement(params).await) .await?; @@ -161,10 +151,6 @@ impl ConfigRequestProcessor { ClientResponsePayload::ExperimentalFeatureEnablementSet(response), ) .await; - if should_refresh_apps_list { - self.refresh_apps_list_after_experimental_feature_enablement_set() - .await; - } Ok(None) } @@ -195,71 +181,6 @@ impl ConfigRequestProcessor { Ok(response) } - async fn refresh_apps_list_after_experimental_feature_enablement_set(&self) { - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(error) => { - tracing::warn!( - "failed to load config for apps list refresh after experimental feature enablement: {}", - error.message - ); - return; - } - }; - let auth = self.auth_manager.auth().await; - if !config.features.apps_enabled_for_auth( - auth.as_ref() - .is_some_and(codex_login::CodexAuth::uses_codex_backend), - ) { - return; - } - - let outgoing = Arc::clone(&self.outgoing); - let environment_manager = self.thread_manager.environment_manager(); - tokio::spawn(async move { - let (all_connectors_result, accessible_connectors_result) = tokio::join!( - connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), - connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - &config, - /*force_refetch*/ true, - Arc::clone(&environment_manager), - ), - ); - let all_connectors = match all_connectors_result { - Ok(connectors) => connectors, - Err(err) => { - tracing::warn!( - "failed to force-refresh directory apps after experimental feature enablement: {err:#}" - ); - return; - } - }; - let accessible_connectors = match accessible_connectors_result { - Ok(status) => status.connectors, - Err(err) => { - tracing::warn!( - "failed to force-refresh accessible apps after experimental feature enablement: {err:#}" - ); - return; - } - }; - - let data = connectors::with_app_enabled_state( - connectors::merge_connectors_with_accessible( - all_connectors, - accessible_connectors, - /*all_connectors_loaded*/ true, - ), - &config, - ); - outgoing - .send_server_notification(ServerNotification::AppListUpdated( - AppListUpdatedNotification { data }, - )) - .await; - }); - } - async fn load_latest_config( &self, fallback_cwd: Option, @@ -317,28 +238,19 @@ impl ConfigRequestProcessor { &self, params: ExperimentalFeatureEnablementSetParams, ) -> Result { - let ExperimentalFeatureEnablementSetParams { enablement } = params; - for key in enablement.keys() { - if canonical_feature_for_key(key).is_some() { - if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) { - continue; - } - - return Err(invalid_request(format!( - "unsupported feature enablement `{key}`: currently supported features are {}", - SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") - ))); + let ExperimentalFeatureEnablementSetParams { mut enablement } = params; + let mut invalid_keys = Vec::new(); + enablement.retain(|key, _| { + let valid = canonical_feature_for_key(key).is_some() + && SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()); + if !valid { + invalid_keys.push(key.clone()); } - - let message = if let Some(feature) = feature_for_key(key) { - format!( - "invalid feature enablement `{key}`: use canonical feature key `{}`", - feature.key() - ) - } else { - format!("invalid feature enablement `{key}`") - }; - return Err(invalid_request(message)); + valid + }); + if !invalid_keys.is_empty() { + let invalid_keys = invalid_keys.join(", "); + tracing::warn!("ignoring invalid experimental feature enablement keys: {invalid_keys}"); } if enablement.is_empty() { diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 2335694856..79791c142a 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason; use codex_core_plugins::remote::RemotePluginScope; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; @@ -1062,6 +1063,7 @@ impl PluginRequestProcessor { }) .collect(), apps: app_summaries, + app_templates: Vec::new(), mcp_servers: outcome.plugin.mcp_server_names, } } @@ -2007,6 +2009,28 @@ fn remote_plugin_detail_to_info( detail: RemoteCatalogPluginDetail, apps: Vec, ) -> PluginDetail { + let app_templates = detail + .app_templates + .into_iter() + .map(|template| AppTemplateSummary { + template_id: template.template_id, + name: template.name, + description: template.description, + canonical_connector_id: template.canonical_connector_id, + logo_url: template.logo_url, + logo_url_dark: template.logo_url_dark, + materialized_app_ids: template.materialized_app_ids, + reason: template.reason.map(|reason| match reason { + RemoteAppTemplateUnavailableReason::NotConfiguredForWorkspace => { + AppTemplateUnavailableReason::NotConfiguredForWorkspace + } + RemoteAppTemplateUnavailableReason::NoActiveWorkspace => { + AppTemplateUnavailableReason::NoActiveWorkspace + } + }), + }) + .collect(); + PluginDetail { marketplace_name: detail.marketplace_name, marketplace_path: None, @@ -2026,7 +2050,8 @@ fn remote_plugin_detail_to_info( .collect(), hooks: Vec::new(), apps, - mcp_servers: Vec::new(), + app_templates, + mcp_servers: detail.mcp_servers, } } diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 265832f9b7..efff8e7cd8 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -158,7 +158,7 @@ fn merge_persisted_resume_metadata( typesafe_overrides.model = persisted_metadata.model.clone(); typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone()); - if let Some(reasoning_effort) = persisted_metadata.reasoning_effort { + if let Some(reasoning_effort) = persisted_metadata.reasoning_effort.as_ref() { request_overrides.get_or_insert_with(HashMap::new).insert( "model_reasoning_effort".to_string(), serde_json::Value::String(reasoning_effort.to_string()), @@ -659,12 +659,6 @@ impl ThreadRequestProcessor { .map(|response| Some(response.into())) } - async fn instruction_sources_from_config(config: &Config) -> Vec { - codex_core::AgentsMdManager::new(config) - .instruction_sources(LOCAL_FS.as_ref()) - .await - } - async fn load_thread( &self, thread_id: &str, @@ -1043,7 +1037,6 @@ impl ThreadRequestProcessor { .map_err(|err| config_load_error(&err))?; } - let instruction_sources = Self::instruction_sources_from_config(&config).await; let environments = environments.unwrap_or_else(|| { listener_task_context .thread_manager @@ -1113,6 +1106,7 @@ impl ThreadRequestProcessor { ) .await?; + let instruction_sources = thread.instruction_sources().await; let config_snapshot = thread .config_snapshot() .instrument(tracing::info_span!( @@ -2524,13 +2518,12 @@ impl ThreadRequestProcessor { } }; - let instruction_sources = Self::instruction_sources_from_config(&config).await; let response_history = thread_history.clone(); match self .thread_manager .resume_thread_with_history( - config.clone(), + config, thread_history, self.auth_manager.clone(), self.request_trace_context(&request_id).await, @@ -2553,6 +2546,7 @@ impl ThreadRequestProcessor { self.outgoing.send_error(request_id, err).await; return Ok(()); } + let instruction_sources = codex_thread.instruction_sources().await; let SessionConfiguredEvent { rollout_path, .. } = session_configured; let Some(rollout_path) = rollout_path else { let error = @@ -2853,10 +2847,7 @@ impl ThreadRequestProcessor { /*include_turns*/ false, ); thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); - let mut config_for_instruction_sources = self.config.as_ref().clone(); - config_for_instruction_sources.cwd = config_snapshot.cwd.clone(); - let instruction_sources = - Self::instruction_sources_from_config(&config_for_instruction_sources).await; + let instruction_sources = existing_thread.instruction_sources().await; let listener_command_tx = { let thread_state = thread_state.lock().await; @@ -3233,7 +3224,6 @@ impl ThreadRequestProcessor { .map_err(|err| config_load_error(&err))?; let fallback_model_provider = config.model_provider_id.clone(); - let instruction_sources = Self::instruction_sources_from_config(&config).await; let NewThread { thread_id, @@ -3284,6 +3274,8 @@ impl ThreadRequestProcessor { .map_err(|err| core_thread_write_error("inherit source thread name", err))?; } + let instruction_sources = forked_thread.instruction_sources().await; + // Auto-attach a conversation listener when forking a thread. log_listener_attach_result( self.ensure_conversation_listener( diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 6be022aa6f..c0a909e577 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -701,6 +701,7 @@ mod thread_processor_behavior_tests { }, }, session_source: SessionSource::Cli, + forked_from_thread_id: None, parent_thread_id: None, thread_source: None, }; diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index a57f2fcf79..f467074828 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -203,7 +203,7 @@ pub(crate) fn thread_settings_from_config_snapshot( model: config_snapshot.model.clone(), model_provider: config_snapshot.model_provider_id.clone(), service_tier: config_snapshot.service_tier.clone(), - effort: config_snapshot.reasoning_effort, + effort: config_snapshot.reasoning_effort.clone(), summary: config_snapshot.reasoning_summary, collaboration_mode: config_snapshot.collaboration_mode.clone(), personality: config_snapshot.personality, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 25beee3cdb..8ab6cf7731 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -629,7 +629,7 @@ impl TurnRequestProcessor { profile_workspace_roots: profile_workspace_roots.clone(), windows_sandbox_level: None, model: model.clone(), - effort, + effort: effort.clone(), summary, service_tier: service_tier.clone(), collaboration_mode: collaboration_mode.clone(), diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 666f6ed7cd..8233b1b296 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -18,7 +18,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { slug: preset.id.clone(), display_name: preset.display_name.clone(), description: Some(preset.description.clone()), - default_reasoning_level: Some(preset.default_reasoning_effort), + default_reasoning_level: Some(preset.default_reasoning_effort.clone()), supported_reasoning_levels: preset.supported_reasoning_efforts.clone(), shell_type: ConfigShellToolType::ShellCommand, visibility: if preset.show_in_picker { @@ -52,6 +52,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, 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 ea7ff8fa4b..2177106f8f 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -1,5 +1,4 @@ use std::borrow::Cow; -use std::collections::BTreeMap; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -28,7 +27,6 @@ use codex_app_server_protocol::AppScreenshot; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -1335,108 +1333,6 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu Ok(()) } -#[tokio::test] -async fn experimental_feature_enablement_set_refreshes_apps_list_when_apps_turn_on() -> Result<()> { - let initial_connectors = vec![AppInfo { - id: "alpha".to_string(), - name: "Alpha".to_string(), - description: Some("Alpha v1".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - }]; - let (server_url, server_handle, server_control) = start_apps_server_with_delays_and_control( - initial_connectors, - Vec::new(), - Duration::ZERO, - Duration::ZERO, - ) - .await?; - - let codex_home = TempDir::new()?; - write_connectors_config(codex_home.path(), &server_url)?; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account-123") - .chatgpt_user_id("user-enable-refresh") - .chatgpt_account_id("account-123"), - AuthCredentialsStoreMode::File, - )?; - - let mut mcp = TestAppServer::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let disable_request = mcp - .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { - enablement: BTreeMap::from([("apps".to_string(), false)]), - }) - .await?; - let _disable_response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(disable_request)), - ) - .await??; - - server_control.set_connectors(vec![AppInfo { - id: "alpha".to_string(), - name: "Alpha".to_string(), - description: Some("Alpha v2".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - }]); - server_control.set_tools(vec![connector_tool("alpha", "Alpha App")?]); - - let enable_request = mcp - .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { - enablement: BTreeMap::from([("apps".to_string(), true)]), - }) - .await?; - let _enable_response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(enable_request)), - ) - .await??; - - let update = read_app_list_updated_notification(&mut mcp).await?; - assert_eq!( - update.data, - vec![AppInfo { - id: "alpha".to_string(), - name: "Alpha".to_string(), - description: Some("Alpha v2".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), - is_accessible: true, - is_enabled: true, - plugin_display_names: Vec::new(), - }] - ); - - server_handle.abort(); - Ok(()) -} - async fn read_app_list_updated_notification( mcp: &mut TestAppServer, ) -> Result { diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 60a0f94839..797736b7af 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -267,13 +267,15 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let actual = - set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) - .await?; + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("auth_elicitation".to_string(), true)]), + ) + .await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { - enablement: BTreeMap::from([("apps".to_string(), true)]), + enablement: BTreeMap::from([("auth_elicitation".to_string(), true)]), } ); @@ -284,7 +286,7 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config config .additional .get("features") - .and_then(|features| features.get("apps")), + .and_then(|features| features.get("auth_elicitation")), Some(&json!(true)) ); } @@ -333,16 +335,18 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) - .await?; + set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("mentions_v2".to_string(), true)]), + ) + .await?; let actual = set_experimental_feature_enablement( &mut mcp, BTreeMap::from([ + ("auth_elicitation".to_string(), true), ("memories".to_string(), true), - ("plugins".to_string(), true), ("remote_plugin".to_string(), true), - ("tool_suggest".to_string(), true), - ("tool_call_mcp_elicitation".to_string(), false), + ("tool_suggest".to_string(), false), ]), ) .await?; @@ -351,11 +355,10 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re actual, ExperimentalFeatureEnablementSetResponse { enablement: BTreeMap::from([ + ("auth_elicitation".to_string(), true), ("memories".to_string(), true), - ("plugins".to_string(), true), ("remote_plugin".to_string(), true), - ("tool_suggest".to_string(), true), - ("tool_call_mcp_elicitation".to_string(), false), + ("tool_suggest".to_string(), false), ]), } ); @@ -366,7 +369,14 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re config .additional .get("features") - .and_then(|features| features.get("apps")), + .and_then(|features| features.get("mentions_v2")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("auth_elicitation")), Some(&json!(true)) ); assert_eq!( @@ -376,13 +386,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("memories")), Some(&json!(true)) ); - assert_eq!( - config - .additional - .get("features") - .and_then(|features| features.get("plugins")), - Some(&json!(true)) - ); assert_eq!( config .additional @@ -395,13 +398,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .additional .get("features") .and_then(|features| features.get("tool_suggest")), - Some(&json!(true)) - ); - assert_eq!( - config - .additional - .get("features") - .and_then(|features| features.get("tool_call_mcp_elicitation")), Some(&json!(false)) ); @@ -432,8 +428,11 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) - .await?; + set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("mentions_v2".to_string(), true)]), + ) + .await?; let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?; assert_eq!( @@ -449,7 +448,7 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> config .additional .get("features") - .and_then(|features| features.get("apps")), + .and_then(|features| features.get("mentions_v2")), Some(&json!(true)) ); @@ -457,36 +456,30 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> } #[tokio::test] -async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> { +async fn experimental_feature_enablement_set_ignores_invalid_features() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let request_id = mcp - .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { - enablement: BTreeMap::from([("personality".to_string(), true)]), - }) - .await?; - let JSONRPCError { error, .. } = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([ + ("apps".to_string(), false), + ("auth_elicitation".to_string(), true), + ("connectors".to_string(), false), + ("personality".to_string(), false), + ("plugins".to_string(), false), + ("tool_call_mcp_elicitation".to_string(), false), + ("unknown_feature".to_string(), true), + ]), ) - .await??; + .await?; - assert_eq!(error.code, -32600); - assert!( - error - .message - .contains("unsupported feature enablement `personality`"), - "{}", - error.message - ); - assert!( - error.message.contains( - "apps, memories, mentions_v2, plugins, remote_control, remote_plugin, tool_suggest, tool_call_mcp_elicitation" - ), - "{}", - error.message + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("auth_elicitation".to_string(), true)]), + } ); Ok(()) diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 3469bf44c1..b6593bc820 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -44,7 +44,7 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] -async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Result<()> { +async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Result<()> { let call_id = "image-run-1"; let server = responses::start_mock_server().await; mount_image_response(&server).await; @@ -124,7 +124,13 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> "detail": "high", }) ); - assert_eq!(output["output"].as_array().map(Vec::len), Some(1)); + let output_hint = output["output"][1]["text"] + .as_str() + .context("image output should include model-visible path hint")?; + assert!( + output_hint.contains(&saved_path.display().to_string()), + "output hint should identify the path core saved" + ); assert!( !requests[1] .message_input_texts("developer") @@ -199,7 +205,7 @@ const result = await tools.image_gen__imagegen({ action: "generate", prompt: "paint a blue whale", }); -image(result); +generatedImage(result); "#, ), responses::ev_completed("resp-1"), @@ -246,7 +252,12 @@ image(result); "detail": "high", }) ); - assert_eq!(output["output"].as_array().map(Vec::len), Some(2)); + assert!( + output["output"][2]["text"] + .as_str() + .is_some_and(|text| text.contains("Generated images are saved")) + ); + assert_eq!(output["output"].as_array().map(Vec::len), Some(3)); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 778577f857..d2538629c8 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use anyhow::Error; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; @@ -48,11 +49,11 @@ fn model_from_preset(preset: &ModelPreset) -> Model { .supported_reasoning_efforts .iter() .map(|preset| ReasoningEffortOption { - reasoning_effort: preset.effort, + reasoning_effort: preset.effort.clone(), description: preset.description.clone(), }) .collect(), - default_reasoning_effort: preset.default_reasoning_effort, + default_reasoning_effort: preset.default_reasoning_effort.clone(), input_modalities: preset.input_modalities.clone(), // `write_models_cache()` round-trips through a simplified ModelInfo fixture that does not // preserve personality placeholders in base instructions, so app-server list results from @@ -164,10 +165,11 @@ async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result< "slug": "chatgpt-remote-only", "display_name": "ChatGPT Remote Only", "description": "Remote-only model for app-server model/list coverage", - "default_reasoning_level": "medium", + "default_reasoning_level": "max", "supported_reasoning_levels": [ - {"effort": "low", "description": "low"}, - {"effort": "medium", "description": "medium"} + {"effort": "max", "description": "Maximum"}, + {"effort": "low", "description": "Low"}, + {"effort": "focused", "description": "Focused"} ], "shell_type": "shell_command", "visibility": "list", @@ -238,10 +240,24 @@ openai_base_url = "{server_uri}/v1" } = to_response::(response)?; let mut expected_presets: Vec = vec![remote_model.into()]; ModelPreset::mark_default_by_picker_visibility(&mut expected_presets); - let expected_items = expected_presets + let mut expected_items = expected_presets .iter() .map(model_from_preset) .collect::>(); + expected_items[0].supported_reasoning_efforts = vec![ + ReasoningEffortOption { + reasoning_effort: "max".parse().map_err(Error::msg)?, + description: "Maximum".to_string(), + }, + ReasoningEffortOption { + reasoning_effort: "low".parse().map_err(Error::msg)?, + description: "Low".to_string(), + }, + ReasoningEffortOption { + reasoning_effort: "focused".parse().map_err(Error::msg)?, + description: "Focused".to_string(), + }, + ]; assert_eq!(items, expected_items); assert!(next_cursor.is_none()); diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index f78cd2a088..e675a6172a 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -17,6 +17,8 @@ use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppTemplateSummary; +use codex_app_server_protocol::AppTemplateUnavailableReason; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; @@ -122,7 +124,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { } #[tokio::test] -async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_is_disabled() -> Result<()> { +async fn plugin_read_returns_remote_mcp_servers_when_uninstalled() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; std::fs::write( @@ -148,22 +150,31 @@ plugins = true let detail_body = r#"{ "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "linear", + "name": "example-plugin", "scope": "GLOBAL", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { - "display_name": "Linear", - "description": "Track work in Linear", + "version": "1.2.1", + "display_name": "Example Plugin", + "description": "Example plugin", "app_ids": [], "keywords": [], "interface": { - "short_description": "Plan and track work", + "short_description": "Example plugin", "capabilities": [], - "default_prompt": "Use the legacy Linear prompt", + "default_prompt": "Use the legacy example prompt", "default_prompts": [] }, - "skills": [] + "skills": [], + "mcp_servers": [ + { + "key": "example-server", + "metadata": { + "command": "example-mcp" + } + } + ] } }"#; let installed_body = r#"{ @@ -211,12 +222,15 @@ plugins = true let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.marketplace_name, "openai-curated-remote"); - assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote"); + assert_eq!( + response.plugin.summary.id, + "example-plugin@openai-curated-remote" + ); assert_eq!( response.plugin.summary.remote_plugin_id.as_deref(), Some("plugins~Plugin_00000000000000000000000000000000") ); - assert_eq!(response.plugin.summary.name, "linear"); + assert_eq!(response.plugin.summary.name, "example-plugin"); assert_eq!(response.plugin.summary.source, PluginSource::Remote); assert_eq!(response.plugin.summary.share_context, None); assert_eq!( @@ -226,7 +240,11 @@ plugins = true .interface .as_ref() .and_then(|interface| interface.default_prompt.clone()), - Some(vec!["Use the legacy Linear prompt".to_string()]) + Some(vec!["Use the legacy example prompt".to_string()]) + ); + assert_eq!( + response.plugin.mcp_servers, + vec!["example-server".to_string()] ); Ok(()) } @@ -411,6 +429,28 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], + "app_templates": [ + { + "template_id": "templated_apps_GitHubEnterprise", + "name": "GitHub Enterprise", + "description": "Connect GitHub Enterprise", + "canonical_connector_id": "github_enterprise", + "logo_url": "https://example.com/ghe-light.png", + "logo_url_dark": "https://example.com/ghe-dark.png", + "materialized_app_ids": ["asdk_app_ghe"], + "reason": null + }, + { + "template_id": "templated_apps_Databricks", + "name": "Databricks", + "description": null, + "canonical_connector_id": null, + "logo_url": null, + "logo_url_dark": null, + "materialized_app_ids": [], + "reason": "NOT_CONFIGURED_FOR_WORKSPACE" + } + ], "keywords": ["issue-tracking", "project management"], "interface": { "short_description": "Plan and track work", @@ -548,6 +588,31 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> assert_eq!(response.plugin.skills[0].path, None); assert_eq!(response.plugin.skills[0].enabled, false); assert_eq!(response.plugin.apps.len(), 0); + assert_eq!( + response.plugin.app_templates, + vec![ + AppTemplateSummary { + template_id: "templated_apps_GitHubEnterprise".to_string(), + name: "GitHub Enterprise".to_string(), + description: Some("Connect GitHub Enterprise".to_string()), + canonical_connector_id: Some("github_enterprise".to_string()), + logo_url: Some("https://example.com/ghe-light.png".to_string()), + logo_url_dark: Some("https://example.com/ghe-dark.png".to_string()), + materialized_app_ids: vec!["asdk_app_ghe".to_string()], + reason: None, + }, + AppTemplateSummary { + template_id: "templated_apps_Databricks".to_string(), + name: "Databricks".to_string(), + description: None, + canonical_connector_id: None, + logo_url: None, + logo_url_dark: None, + materialized_app_ids: Vec::new(), + reason: Some(AppTemplateUnavailableReason::NotConfiguredForWorkspace), + }, + ] + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index ff9f655630..a335b441ff 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -490,6 +490,13 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { "forked", "user", ); + assert_eq!( + event["event_params"]["forked_from_thread_id"], + thread + .forked_from_id + .as_deref() + .expect("forked thread has a source thread") + ); Ok(()) } 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 d18a405164..4ae68b0e71 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -256,6 +256,82 @@ async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let workspace = TempDir::new()?; + let project_agents = workspace.path().join("AGENTS.md"); + std::fs::write(&project_agents, "project instructions")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { + thread, + instruction_sources, + .. + } = to_response::(start_resp)?; + let project_agents = AbsolutePathBuf::try_from(project_agents)?; + assert_eq!(instruction_sources, vec![project_agents.clone()]); + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Text { + text: "materialize rollout".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + std::fs::remove_file(project_agents.as_path())?; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + instruction_sources, + .. + } = to_response::(resume_resp)?; + + assert_eq!(instruction_sources, vec![project_agents]); + + Ok(()) +} + #[tokio::test] async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 7a7ed5522a..a2da95545a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -348,7 +348,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( .collect::>(); let expected_instruction_sources = vec![ std::fs::canonicalize(global_agents_path)?, - std::fs::canonicalize(project_agents_path)?, + project_agents_path, ] .into_iter() .map(normalize_path_for_comparison) @@ -359,6 +359,83 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( Ok(()) } +#[tokio::test] +async fn thread_start_response_excludes_empty_project_instruction_source() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + let global_agents_path = codex_home.path().join("AGENTS.md"); + std::fs::write(&global_agents_path, "global instructions")?; + let workspace = TempDir::new()?; + let project_agents_path = workspace.path().join("AGENTS.md"); + std::fs::write(project_agents_path, "")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + instruction_sources, + .. + } = to_response::(response)?; + + let instruction_sources = instruction_sources + .into_iter() + .map(normalize_path_for_comparison) + .collect::>(); + let expected_instruction_sources = vec![normalize_path_for_comparison(std::fs::canonicalize( + global_agents_path, + )?)]; + + assert_eq!(instruction_sources, expected_instruction_sources); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_without_selected_environment_excludes_instruction_sources() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + std::fs::write(codex_home.path().join("AGENTS.md"), "global instructions")?; + let workspace = TempDir::new()?; + std::fs::write(workspace.path().join("AGENTS.md"), "project instructions")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + environments: Some(Vec::new()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + instruction_sources, + .. + } = to_response::(response)?; + + assert!(instruction_sources.is_empty()); + + Ok(()) +} + #[cfg(windows)] fn normalize_path_for_comparison(path: impl AsRef) -> PathBuf { let path = path.as_ref(); diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 9ed2c1be6e..c850556e4d 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -20,6 +20,7 @@ use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_cli::CliConfigOverrides; use serde::Serialize; +use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; @@ -177,9 +178,14 @@ pub async fn run_plugin_list( .is_none_or(|name| marketplace.name == *name) }) .collect::>(); + let marketplace_sources = configured_marketplace_sources(&plugins_input); if args.json { - let output = JsonPluginListOutput::from_marketplaces(marketplaces, args.available); + let output = JsonPluginListOutput::from_marketplaces( + marketplaces, + args.available, + &marketplace_sources, + ); println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } @@ -269,13 +275,19 @@ impl JsonPluginListOutput { fn from_marketplaces( marketplaces: Vec, include_available: bool, + marketplace_sources: &HashMap, ) -> Self { let mut installed = Vec::new(); let mut available = Vec::new(); for marketplace in marketplaces { + let marketplace_source = marketplace_sources.get(&marketplace.name).cloned(); for plugin in marketplace.plugins { - let entry = JsonPluginListEntry::from_configured_plugin(&marketplace.name, plugin); + let entry = JsonPluginListEntry::from_configured_plugin( + &marketplace.name, + marketplace_source.clone(), + plugin, + ); if entry.installed { installed.push(entry); } else if include_available { @@ -301,6 +313,8 @@ struct JsonPluginListEntry { installed: bool, enabled: bool, source: JsonPluginSource, + #[serde(skip_serializing_if = "Option::is_none")] + marketplace_source: Option, install_policy: &'static str, auth_policy: &'static str, } @@ -308,6 +322,7 @@ struct JsonPluginListEntry { impl JsonPluginListEntry { fn from_configured_plugin( marketplace_name: &str, + marketplace_source: Option, plugin: codex_core_plugins::ConfiguredMarketplacePlugin, ) -> Self { let version = plugin.installed_version.or(plugin.local_version); @@ -319,6 +334,7 @@ impl JsonPluginListEntry { installed: plugin.installed, enabled: plugin.enabled, source: JsonPluginSource::from_marketplace_source(plugin.source), + marketplace_source, install_policy: install_policy_label(plugin.policy.installation), auth_policy: auth_policy_label(plugin.policy.authentication), } @@ -375,6 +391,44 @@ impl JsonPluginSource { } } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceSource { + source_type: String, + source: String, +} + +fn configured_marketplace_sources( + plugins_input: &PluginsConfigInput, +) -> HashMap { + let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else { + return HashMap::new(); + }; + let Some(marketplaces) = user_config + .get("marketplaces") + .and_then(toml::Value::as_table) + else { + return HashMap::new(); + }; + + marketplaces + .iter() + .filter_map(|(marketplace_name, marketplace)| { + let source_type = marketplace + .get("source_type") + .and_then(toml::Value::as_str)?; + let source = marketplace.get("source").and_then(toml::Value::as_str)?; + Some(( + marketplace_name.clone(), + JsonMarketplaceSource { + source_type: source_type.to_string(), + source: source.to_string(), + }, + )) + }) + .collect() +} + fn install_policy_label(policy: MarketplacePluginInstallPolicy) -> &'static str { match policy { MarketplacePluginInstallPolicy::NotAvailable => "NOT_AVAILABLE", diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index 4ce3617211..2625e0b68b 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -2,6 +2,7 @@ use anyhow::Result; use codex_config::CONFIG_TOML_FILE; use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; +use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use pretty_assertions::assert_eq; @@ -495,6 +496,7 @@ async fn plugin_list_prints_plugins_in_a_table() -> Result<()> { async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()> { let (codex_home, source) = setup_local_marketplace()?; let plugin_path = source.path().join("plugins").join("sample"); + let source_path = source.path().to_string_lossy().into_owned(); let assert = codex_command(codex_home.path())? .args(["plugin", "list", "--available", "--json"]) @@ -519,6 +521,69 @@ async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<() "source": "local", "path": plugin_path.display().to_string(), }, + "marketplaceSource": { + "sourceType": "local", + "source": source_path, + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + }, + ], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_json_includes_configured_git_marketplace_source() -> Result<()> { + let codex_home = TempDir::new()?; + let marketplace_root = codex_home + .path() + .join(".tmp") + .join("marketplaces") + .join("debug"); + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source(&marketplace_root)?; + let update = MarketplaceConfigUpdate { + last_updated: "2026-06-04T08:39:49Z", + last_revision: Some("abc123"), + source_type: "git", + source: "https://example.com/acme/agent-skills.git", + ref_name: None, + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update)?; + let plugin_path = marketplace_root.join("plugins").join("sample"); + let normalized_plugin_path = canonicalize_existing_preserving_symlinks(&plugin_path)?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "list", "--available", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "installed": [], + "available": [ + { + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installed": false, + "enabled": false, + "source": { + "source": "local", + "path": normalized_plugin_path.display().to_string(), + }, + "marketplaceSource": { + "sourceType": "git", + "source": "https://example.com/acme/agent-skills.git", + }, "installPolicy": "AVAILABLE", "authPolicy": "ON_INSTALL", }, @@ -533,6 +598,7 @@ async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<() async fn plugin_list_json_prints_installed_plugins() -> Result<()> { let (codex_home, source) = setup_local_marketplace()?; let plugin_path = source.path().join("plugins").join("sample"); + let source_path = source.path().to_string_lossy().into_owned(); codex_command(codex_home.path())? .args(["plugin", "add", "sample@debug"]) @@ -561,6 +627,10 @@ async fn plugin_list_json_prints_installed_plugins() -> Result<()> { "source": "local", "path": plugin_path.display().to_string(), }, + "marketplaceSource": { + "sourceType": "local", + "source": source_path, + }, "installPolicy": "AVAILABLE", "authPolicy": "ON_INSTALL", }, diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 50fcc034ba..7e0801ed25 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -25,6 +25,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - `exit()`: Immediately ends the current script successfully (like an early return from the top level). - `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible. - `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. +- `generatedImage(result: { image_url: string; output_hint?: string })`: Appends an image-generation result and its optional output hint. - `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. - `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. - `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode/src/runtime/callbacks.rs index 7d8a28662a..dde63617ec 100644 --- a/codex-rs/code-mode/src/runtime/callbacks.rs +++ b/codex-rs/code-mode/src/runtime/callbacks.rs @@ -129,6 +129,58 @@ pub(super) fn image_callback( retval.set(v8::undefined(scope).into()); } +pub(super) fn generated_image_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let output_hint = match generated_image_output_hint(scope, value) { + Ok(output_hint) => output_hint, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + let image_item = match normalize_output_image(scope, value, /*detail_override*/ None) { + Ok(image_item) => image_item, + Err(()) => return, + }; + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item)); + if let Some(text) = output_hint { + let _ = state.event_tx.send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { text }, + )); + } + } + retval.set(v8::undefined(scope).into()); +} + +fn generated_image_output_hint( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result, String> { + let object = v8::Local::::try_from(value) + .map_err(|_| "generatedImage expects an image generation result object".to_string())?; + let key = v8::String::new(scope, "output_hint") + .ok_or_else(|| "failed to allocate generatedImage helper keys".to_string())?; + let output_hint = object + .get(scope, key.into()) + .ok_or_else(|| "failed to read generatedImage output_hint".to_string())?; + if output_hint.is_undefined() { + return Ok(None); + } + if !output_hint.is_string() { + return Err("generatedImage output_hint must be a string when provided".to_string()); + } + Ok(Some(output_hint.to_rust_string_lossy(scope))) +} + pub(super) fn store_callback( scope: &mut v8::PinScope<'_, '_>, args: v8::FunctionCallbackArguments, diff --git a/codex-rs/code-mode/src/runtime/globals.rs b/codex-rs/code-mode/src/runtime/globals.rs index 2ec6953f09..fe3df4c958 100644 --- a/codex-rs/code-mode/src/runtime/globals.rs +++ b/codex-rs/code-mode/src/runtime/globals.rs @@ -1,6 +1,7 @@ use super::RuntimeState; use super::callbacks::clear_timeout_callback; use super::callbacks::exit_callback; +use super::callbacks::generated_image_callback; use super::callbacks::image_callback; use super::callbacks::load_callback; use super::callbacks::notify_callback; @@ -23,6 +24,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?; let text = helper_function(scope, "text", text_callback)?; let image = helper_function(scope, "image", image_callback)?; + let generated_image = helper_function(scope, "generatedImage", generated_image_callback)?; let store = helper_function(scope, "store", store_callback)?; let load = helper_function(scope, "load", load_callback)?; let notify = helper_function(scope, "notify", notify_callback)?; @@ -35,6 +37,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St set_global(scope, global, "setTimeout", set_timeout.into())?; set_global(scope, global, "text", text.into())?; set_global(scope, global, "image", image.into())?; + set_global(scope, global, "generatedImage", generated_image.into())?; set_global(scope, global, "store", store.into())?; set_global(scope, global, "load", load.into())?; set_global(scope, global, "notify", notify.into())?; diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 6589e6ec5a..b348d1d35f 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -1563,6 +1563,44 @@ image({ ); } + #[tokio::test] + async fn generated_image_helper_appends_image_and_output_hint() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +generatedImage({ + image_url: "https://example.com/image.jpg", + output_hint: "generated image save hint", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![ + FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/image.jpg".to_string(), + detail: Some(crate::DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { + text: "generated image save hint".to_string(), + }, + ], + error_text: None, + } + ); + } + #[tokio::test] async fn image_helper_second_arg_overrides_explicit_object_detail() { let service = CodeModeService::new(); diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 91b251c41f..12f8cc55da 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -110,12 +110,22 @@ pub enum ResponseEvent { ModelsEtag(String), } +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningContext { + Auto, + CurrentTurn, + AllTurns, +} + #[derive(Debug, Serialize, Clone, PartialEq)] pub struct Reasoning { #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, } #[derive(Debug, Serialize, Default, Clone, PartialEq)] @@ -239,11 +249,6 @@ pub struct ResponseCreateWsRequest { pub client_metadata: Option>, } -#[derive(Debug, Serialize)] -pub struct ResponseProcessedWsRequest { - pub response_id: String, -} - pub fn response_create_client_metadata( client_metadata: Option>, trace: Option<&W3cTraceContext>, @@ -272,8 +277,6 @@ pub fn response_create_client_metadata( pub enum ResponsesWsRequest { #[serde(rename = "response.create")] ResponseCreate(ResponseCreateWsRequest), - #[serde(rename = "response.processed")] - ResponseProcessed(ResponseProcessedWsRequest), } pub fn create_text_param_for_request( diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index f0a0019817..a7366a9871 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -1,6 +1,5 @@ use crate::auth::SharedAuthProvider; use crate::common::ResponseEvent; -use crate::common::ResponseProcessedWsRequest; use crate::common::ResponseStream; use crate::common::ResponsesWsRequest; use crate::error::ApiError; @@ -206,40 +205,6 @@ impl ResponsesWebsocketConnection { self.stream.lock().await.is_none() } - #[instrument( - name = "responses_websocket.send_response_processed", - level = "info", - skip_all, - fields(transport = "responses_websocket", api.path = "responses") - )] - #[expect( - clippy::await_holding_invalid_type, - reason = "the guard serializes exclusive use of the websocket while sending a request frame" - )] - pub async fn send_response_processed(&self, response_id: String) -> Result<(), ApiError> { - let request = - ResponsesWsRequest::ResponseProcessed(ResponseProcessedWsRequest { response_id }); - let request_body = serde_json::to_value(&request).map_err(|err| { - ApiError::Stream(format!("failed to encode websocket request: {err}")) - })?; - - let mut guard = self.stream.lock().await; - let Some(ws_stream) = guard.as_mut() else { - return Err(ApiError::Stream( - "websocket connection is closed".to_string(), - )); - }; - - send_websocket_request( - ws_stream, - request_body, - self.idle_timeout, - self.telemetry.as_ref(), - /*connection_reused*/ true, - ) - .await - } - #[instrument( name = "responses_websocket.stream_request", level = "info", diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 08176d8dec..d2d7679406 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -30,9 +30,9 @@ pub use crate::common::OpenAiVerbosity; pub use crate::common::RawMemory; pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; +pub use crate::common::ReasoningContext; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; -pub use crate::common::ResponseProcessedWsRequest; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; pub use crate::common::ResponsesWsRequest; diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 9ff273211f..4ee6069898 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -98,6 +98,7 @@ async fn models_client_hits_models_endpoint() { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index e0d2c70671..6b012494d9 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -25,6 +25,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { if feature.id == codex_features::Feature::Artifact { continue; } + if feature.id == codex_features::Feature::CodeMode { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } if feature.id == codex_features::Feature::MultiAgentV2 { validation.properties.insert( feature.key.to_string(), diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index e87ee82f30..d1c7c7a18b 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -26,6 +26,7 @@ pub use codex_config::types::TuiPetAnchor; pub use codex_config::types::UriBasedFileOpener; pub use codex_core::CodexThread; pub use codex_core::ForkSnapshot; +pub use codex_core::LoadedAgentsMd; pub use codex_core::McpManager; pub use codex_core::NewThread; pub use codex_core::StartThreadOptions; diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 045ad8e184..7daf1da31f 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -167,6 +167,27 @@ pub struct RemotePluginDetail { pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, + pub app_templates: Vec, + pub mcp_servers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAppTemplate { + pub template_id: String, + pub name: String, + pub description: Option, + pub canonical_connector_id: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub materialized_app_ids: Vec, + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RemoteAppTemplateUnavailableReason { + NotConfiguredForWorkspace, + NoActiveWorkspace, } #[derive(Debug, Clone, PartialEq)] @@ -412,11 +433,38 @@ struct RemotePluginReleaseResponse { app_ids: Vec, #[serde(default)] app_manifest: Option, + #[serde(default, alias = "unavailable_app_templates")] + app_templates: Vec, #[serde(default)] keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + mcp_servers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct RemotePluginMcpServerResponse { + key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct RemoteAppTemplateResponse { + template_id: String, + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + canonical_connector_id: Option, + #[serde(default)] + logo_url: Option, + #[serde(default)] + logo_url_dark: Option, + #[serde(default)] + materialized_app_ids: Vec, + #[serde(default)] + reason: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -948,6 +996,14 @@ async fn build_remote_plugin_detail( enabled: !disabled_skill_names.contains(&skill.name), }) .collect(); + let mut mcp_servers = plugin + .release + .mcp_servers + .iter() + .map(|server| server.key.clone()) + .collect::>(); + mcp_servers.sort_unstable(); + mcp_servers.dedup(); Ok(RemotePluginDetail { marketplace_name, @@ -959,6 +1015,22 @@ async fn build_remote_plugin_detail( app_manifest: plugin.release.app_manifest, skills, app_ids: plugin.release.app_ids, + app_templates: plugin + .release + .app_templates + .into_iter() + .map(|template| RemoteAppTemplate { + template_id: template.template_id, + name: template.name, + description: template.description, + canonical_connector_id: template.canonical_connector_id, + logo_url: template.logo_url, + logo_url_dark: template.logo_url_dark, + materialized_app_ids: template.materialized_app_ids, + reason: template.reason, + }) + .collect(), + mcp_servers, }) } diff --git a/codex-rs/core-plugins/src/startup_sync.rs b/codex-rs/core-plugins/src/startup_sync.rs index f03aff93f6..c5965f2212 100644 --- a/codex-rs/core-plugins/src/startup_sync.rs +++ b/codex-rs/core-plugins/src/startup_sync.rs @@ -1,3 +1,4 @@ +use std::fs::File; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -22,8 +23,11 @@ const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str = "https://chatgpt.com/backend-api/plugins/export/curated"; const OPENAI_PLUGINS_OWNER: &str = "openai"; const OPENAI_PLUGINS_REPO: &str = "plugins"; +const OPENAI_PLUGINS_GIT_URL: &str = "https://github.com/openai/plugins.git"; +const CURATED_PLUGINS_FETCH_REF: &str = "refs/codex/curated-sync"; const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; +const CURATED_PLUGINS_SYNC_LOCK_FILE: &str = ".tmp/plugins.sync.lock"; const CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION: &str = "export-backup"; const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30); const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30); @@ -78,6 +82,8 @@ fn sync_openai_plugins_repo_with_transport_overrides( api_base_url: &str, backup_archive_api_url: &str, ) -> Result { + let _file_guard = lock_curated_plugins_startup_sync(codex_home)?; + match sync_openai_plugins_repo_via_git(codex_home, git_binary) { Ok(remote_sha) => { emit_curated_plugins_startup_sync_metric("git", "success"); @@ -135,6 +141,22 @@ fn sync_openai_plugins_repo_with_transport_overrides( } } +fn lock_curated_plugins_startup_sync(codex_home: &Path) -> Result { + let lock_path = codex_home.join(CURATED_PLUGINS_SYNC_LOCK_FILE); + std::fs::create_dir_all(codex_home.join(".tmp")) + .map_err(|err| format!("failed to create curated plugins sync directory: {err}"))?; + let lock_file = File::options() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|err| format!("failed to open curated plugins sync lock: {err}"))?; + lock_file + .lock() + .map_err(|err| format!("failed to lock curated plugins sync: {err}"))?; + Ok(lock_file) +} + fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result { let repo_path = curated_plugins_repo_path(codex_home); let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); @@ -146,23 +168,30 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu } let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; - let clone_output = run_git_command_with_timeout( - Command::new(git_binary) - .env("GIT_OPTIONAL_LOCKS", "0") - .arg("clone") - .arg("--depth") - .arg("1") - .arg("https://github.com/openai/plugins.git") - .arg(staged_repo_dir.path()), - "git clone curated plugins repo", - CURATED_PLUGINS_GIT_TIMEOUT, + run_git_in_repo( + staged_repo_dir.path(), + git_binary, + &["init"], + "git init curated plugins repo", )?; - ensure_git_success(&clone_output, "git clone curated plugins repo")?; - let cloned_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; - if cloned_sha != remote_sha { + if repo_path.join(".git").is_dir() { + fetch_curated_plugins_commit(&repo_path, &remote_sha, git_binary)?; + fetch_curated_plugins_commit_from_source( + staged_repo_dir.path(), + &repo_path, + CURATED_PLUGINS_FETCH_REF, + git_binary, + )?; + } else { + fetch_curated_plugins_commit(staged_repo_dir.path(), &remote_sha, git_binary)?; + } + + reset_curated_plugins_checkout(staged_repo_dir.path(), git_binary)?; + let fetched_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; + if fetched_sha != remote_sha { return Err(format!( - "curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}" + "curated plugins fetch HEAD mismatch: expected {remote_sha}, got {fetched_sha}" )); } @@ -172,6 +201,90 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu Ok(remote_sha) } +fn fetch_curated_plugins_commit( + repo_path: &Path, + remote_sha: &str, + git_binary: &str, +) -> Result<(), String> { + fetch_curated_plugins_commit_from( + repo_path, + OPENAI_PLUGINS_GIT_URL.as_ref(), + remote_sha, + git_binary, + "git fetch curated plugins repo", + ) +} + +fn fetch_curated_plugins_commit_from_source( + repo_path: &Path, + source_repo_path: &Path, + remote_sha: &str, + git_binary: &str, +) -> Result<(), String> { + fetch_curated_plugins_commit_from( + repo_path, + source_repo_path, + remote_sha, + git_binary, + "git copy fetched curated plugins commit", + ) +} + +fn fetch_curated_plugins_commit_from( + repo_path: &Path, + source: &Path, + source_revision: &str, + git_binary: &str, + context: &str, +) -> Result<(), String> { + let fetch_refspec = format!("+{source_revision}:{CURATED_PLUGINS_FETCH_REF}"); + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .args(["fetch", "--depth", "1", "--no-tags"]) + .arg(source) + .arg(fetch_refspec), + context, + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, context) +} + +fn reset_curated_plugins_checkout(repo_path: &Path, git_binary: &str) -> Result<(), String> { + run_git_in_repo( + repo_path, + git_binary, + &["reset", "--hard", CURATED_PLUGINS_FETCH_REF], + "git reset curated plugins repo", + )?; + run_git_in_repo( + repo_path, + git_binary, + &["clean", "-fdx"], + "git clean curated plugins repo", + ) +} + +fn run_git_in_repo( + repo_path: &Path, + git_binary: &str, + args: &[&str], + context: &str, +) -> Result<(), String> { + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .args(args), + context, + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, context) +} + fn sync_openai_plugins_repo_via_http( codex_home: &Path, api_base_url: &str, diff --git a/codex-rs/core-plugins/src/startup_sync_tests.rs b/codex-rs/core-plugins/src/startup_sync_tests.rs index a9388e3fce..f73e4ad9d1 100644 --- a/codex-rs/core-plugins/src/startup_sync_tests.rs +++ b/codex-rs/core-plugins/src/startup_sync_tests.rs @@ -3,6 +3,8 @@ use pretty_assertions::assert_eq; use std::io::Write; use std::path::Path; use std::path::PathBuf; +#[cfg(unix)] +use std::sync::Barrier; use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; @@ -95,6 +97,22 @@ fn write_executable_script(path: &Path, contents: &str) { } } +#[cfg(unix)] +fn run_git(repo: &Path, args: &[&str]) -> std::process::Output { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + output +} + async fn mount_github_repo_and_ref(server: &MockServer, sha: &str) { Mock::given(method("GET")) .and(path("/repos/openai/plugins")) @@ -253,30 +271,42 @@ fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() { #[cfg(unix)] #[test] -fn sync_openai_plugins_repo_prefers_git_when_available() { +fn concurrent_syncs_serialize_fetches_without_skipping_remote_checks() { let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-") .tempdir() .expect("tempdir"); let git_path = bin_dir.path().join("git"); + let invocation_log = bin_dir.path().join("invocations.log"); let sha = "0123456789abcdef0123456789abcdef01234567"; write_executable_script( &git_path, &format!( r#"#!/bin/sh +printf '%s\n' "$*" >> '{}' if [ "$1" = "ls-remote" ]; then + sleep 1 printf '%s\tHEAD\n' "{sha}" exit 0 fi -if [ "$1" = "clone" ]; then - dest="$5" - mkdir -p "$dest/.git" "$dest/.agents/plugins" "$dest/plugins/gmail/.codex-plugin" - cat > "$dest/.agents/plugins/marketplace.json" <<'EOF' +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "reset" ]; then + mkdir -p "$2/.agents/plugins" "$2/plugins/gmail/.codex-plugin" + cat > "$2/.agents/plugins/marketplace.json" <<'EOF' {{"name":"openai-curated","plugins":[{{"name":"gmail","source":{{"source":"local","path":"./plugins/gmail"}}}}]}} EOF - printf '%s\n' '{{"name":"gmail"}}' > "$dest/plugins/gmail/.codex-plugin/plugin.json" + printf '%s\n' '{{"name":"gmail"}}' > "$2/plugins/gmail/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "clean" ]; then exit 0 fi if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then @@ -285,23 +315,55 @@ if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then fi echo "unexpected git invocation: $@" >&2 exit 1 -"# +"#, + invocation_log.display() ), ); - let synced_sha = sync_openai_plugins_repo_with_transport_overrides( - tmp.path(), - git_path.to_str().expect("utf8 path"), - "http://127.0.0.1:9", - "http://127.0.0.1:9/backend-api/plugins/export/curated", - ) - .expect("git sync should succeed"); + let barrier = Barrier::new(2); + let results = std::thread::scope(|scope| { + let run_sync = || { + barrier.wait(); + sync_openai_plugins_repo_with_transport_overrides( + tmp.path(), + git_path.to_str().expect("utf8 path"), + "http://127.0.0.1:9", + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + }; + let first = scope.spawn(run_sync); + let second = scope.spawn(run_sync); + [ + first.join().expect("first sync thread"), + second.join().expect("second sync thread"), + ] + }); - assert_eq!(synced_sha, sha); + assert_eq!(results, [Ok(sha.to_string()), Ok(sha.to_string())]); let repo_path = curated_plugins_repo_path(tmp.path()); assert!(repo_path.join(".git").is_dir()); assert_curated_gmail_repo(&repo_path); assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); + let invocations = std::fs::read_to_string(invocation_log).expect("read invocation log"); + assert_eq!( + invocations + .lines() + .filter(|invocation| invocation.starts_with("ls-remote ")) + .count(), + 2 + ); + assert_eq!( + invocations + .lines() + .filter(|invocation| invocation.contains(" fetch --depth 1 --no-tags ")) + .count(), + 1 + ); + assert!( + !invocations + .lines() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); } #[cfg(unix)] @@ -328,36 +390,20 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { ) .expect("write plugin manifest"); - let init_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("init") - .status() - .expect("run git init"); - assert!(init_status.success()); - - let add_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("add") - .arg(".") - .status() - .expect("run git add"); - assert!(add_status.success()); - - let commit_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("-c") - .arg("user.name=Codex Test") - .arg("-c") - .arg("user.email=codex@example.com") - .arg("commit") - .arg("-m") - .arg("init") - .status() - .expect("run git commit"); - assert!(commit_status.success()); + run_git(&work_repo, &["init"]); + run_git(&work_repo, &["add", "."]); + run_git( + &work_repo, + &[ + "-c", + "user.name=Codex Test", + "-c", + "user.email=codex@example.com", + "commit", + "-m", + "init", + ], + ); std::fs::create_dir_all(remote_repo.parent().expect("remote parent")) .expect("create remote parent"); @@ -370,14 +416,7 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { .expect("run git clone --bare"); assert!(clone_status.success()); - let sha_output = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("rev-parse") - .arg("HEAD") - .output() - .expect("run git rev-parse"); - assert!(sha_output.status.success()); + let sha_output = run_git(&work_repo, &["rev-parse", "HEAD"]); let sha = String::from_utf8_lossy(&sha_output.stdout) .trim() .to_string(); @@ -397,10 +436,12 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { .tempdir() .expect("tempdir"); let git_wrapper = bin_dir.path().join("git"); + let invocation_log = bin_dir.path().join("invocations.log"); write_executable_script( &git_wrapper, &format!( - "#!/bin/sh\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + invocation_log.display(), git_config_path.display() ), ); @@ -416,6 +457,125 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { Some(sha.as_str()) ); assert!(!has_plugins_clone_dirs(tmp.path())); + + let first_sync_invocation_count = std::fs::read_to_string(&invocation_log) + .expect("read first sync invocations") + .lines() + .count(); + let first_sync_invocations = + std::fs::read_to_string(&invocation_log).expect("read first sync invocations"); + assert!( + first_sync_invocations + .lines() + .any(|invocation| invocation.contains(" fetch --depth 1 --no-tags ")) + ); + assert!( + !first_sync_invocations + .lines() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); + write_openai_curated_marketplace(&work_repo, &["gmail", "linear"]); + run_git(&work_repo, &["add", "."]); + run_git( + &work_repo, + &[ + "-c", + "user.name=Codex Test", + "-c", + "user.email=codex@example.com", + "commit", + "-m", + "update", + ], + ); + let branch_output = run_git(&work_repo, &["symbolic-ref", "--short", "HEAD"]); + let branch = String::from_utf8_lossy(&branch_output.stdout) + .trim() + .to_string(); + let remote_repo = remote_repo.to_str().expect("utf8 remote repo"); + let push_ref = format!("HEAD:refs/heads/{branch}"); + run_git(&work_repo, &["push", remote_repo, &push_ref]); + let updated_sha_output = run_git(&work_repo, &["rev-parse", "HEAD"]); + let updated_sha = String::from_utf8_lossy(&updated_sha_output.stdout) + .trim() + .to_string(); + + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("incremental git sync should succeed"); + + assert_eq!(synced_sha, updated_sha); + assert!( + curated_plugins_repo_path(tmp.path()) + .join("plugins/linear/.codex-plugin/plugin.json") + .is_file() + ); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(updated_sha.as_str()) + ); + assert!( + !curated_plugins_repo_path(tmp.path()) + .join(".git/objects/info/alternates") + .exists() + ); + let invocation_log_contents = + std::fs::read_to_string(&invocation_log).expect("read sync invocations"); + let incremental_sync_invocations = invocation_log_contents + .lines() + .skip(first_sync_invocation_count) + .collect::>(); + let curated_repo_path = curated_plugins_repo_path(tmp.path()); + assert!(incremental_sync_invocations.iter().any(|invocation| { + invocation.starts_with(&format!("-C {} fetch ", curated_repo_path.display())) + && invocation.contains(" https://github.com/openai/plugins.git ") + && invocation.contains(updated_sha.as_str()) + && invocation.ends_with(CURATED_PLUGINS_FETCH_REF) + })); + assert!(incremental_sync_invocations.iter().any(|invocation| { + invocation.contains(" fetch --depth 1 --no-tags ") + && invocation.contains(&format!(" {} ", curated_repo_path.display())) + && invocation.ends_with(&format!( + "{CURATED_PLUGINS_FETCH_REF}:{CURATED_PLUGINS_FETCH_REF}" + )) + })); + assert!( + incremental_sync_invocations + .iter() + .any(|invocation| invocation.ends_with(" init")) + ); + assert!( + !incremental_sync_invocations + .iter() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); + assert!(!incremental_sync_invocations.iter().any(|invocation| { + invocation.starts_with(&format!("-C {} reset ", curated_repo_path.display())) + || invocation.starts_with(&format!("-C {} clean ", curated_repo_path.display())) + })); + assert!(!has_plugins_clone_dirs(tmp.path())); + + let unchanged_sync_invocation_count = invocation_log_contents.lines().count(); + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("unchanged git sync should succeed"); + + assert_eq!(synced_sha, updated_sha); + let invocation_log = std::fs::read_to_string(&invocation_log).expect("read sync invocations"); + let unchanged_sync_invocations = invocation_log + .lines() + .skip(unchanged_sync_invocation_count) + .collect::>(); + assert!( + unchanged_sync_invocations + .iter() + .any(|invocation| invocation.starts_with("ls-remote ")) + ); + assert!( + !unchanged_sync_invocations + .iter() + .any(|invocation| invocation.contains(" fetch ")) + ); } #[tokio::test] @@ -482,7 +642,7 @@ exit 1 #[cfg(unix)] #[test] -fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() { +fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_fetch_failure() { let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-partial-fail-") @@ -499,9 +659,11 @@ if [ "$1" = "ls-remote" ]; then printf '%s\tHEAD\n' "{sha}" exit 0 fi -if [ "$1" = "clone" ]; then - dest="$5" - mkdir -p "$dest/.git" +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then echo "fatal: early EOF" >&2 exit 128 fi @@ -518,6 +680,77 @@ exit 1 assert!(!has_plugins_clone_dirs(tmp.path())); } +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_via_git_preserves_existing_snapshot_on_validation_failure() { + let tmp = tempdir().expect("tempdir"); + let repo_path = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&repo_path, &["gmail"]); + std::fs::create_dir_all(repo_path.join(".git")).expect("create git dir"); + write_curated_plugin_sha(tmp.path()); + + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-invalid-update-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let remote_sha = "fedcba9876543210fedcba9876543210fedcba98"; + + write_executable_script( + &git_path, + &format!( + r#"#!/bin/sh +if [ "$1" = "ls-remote" ]; then + printf '%s\tHEAD\n' "{remote_sha}" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$2" = "{}" ] && [ "$3" = "rev-parse" ]; then + printf '%s\n' "{TEST_CURATED_PLUGIN_SHA}" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$2" = "{}" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "reset" ]; then + mkdir -p "$2/plugins/linear/.codex-plugin" + printf '%s\n' '{{"name":"linear"}}' > "$2/plugins/linear/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "clean" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ]; then + printf '%s\n' "{remote_sha}" + exit 0 +fi +echo "unexpected git invocation: $@" >&2 +exit 1 +"#, + repo_path.display(), + repo_path.display(), + ), + ); + + let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path")) + .expect_err("invalid staged checkout should fail"); + + assert!(err.contains("curated plugins archive missing marketplace manifest")); + assert_curated_gmail_repo(&repo_path); + assert!(!repo_path.join("plugins/linear").exists()); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(TEST_CURATED_PLUGIN_SHA) + ); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + #[tokio::test] async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failure() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b281b44bb7..f5a97b142a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -355,6 +355,22 @@ }, "type": "object" }, + "CodeModeConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded_tool_namespaces": { + "description": "Exact tool namespaces to omit from the code-mode nested tool surface.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "ConfigProfile": { "additionalProperties": false, "description": "Collection of common configuration options that a user can define as a unit in `config.toml`.", @@ -410,7 +426,7 @@ "type": "boolean" }, "code_mode": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_CodeModeConfigToml" }, "code_mode_only": { "type": "boolean" @@ -556,9 +572,6 @@ "request_rule": { "type": "boolean" }, - "responses_websocket_response_processed": { - "type": "boolean" - }, "responses_websockets": { "type": "boolean" }, @@ -830,6 +843,16 @@ } ] }, + "FeatureToml_for_CodeModeConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/CodeModeConfigToml" + } + ] + }, "FeatureToml_for_MultiAgentV2ConfigToml": { "anyOf": [ { @@ -2480,15 +2503,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { @@ -4533,7 +4549,7 @@ "type": "boolean" }, "code_mode": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_CodeModeConfigToml" }, "code_mode_only": { "type": "boolean" @@ -4679,9 +4695,6 @@ "request_rule": { "type": "boolean" }, - "responses_websocket_response_processed": { - "type": "boolean" - }, "responses_websockets": { "type": "boolean" }, @@ -5205,4 +5218,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 8048552480..5739e42f30 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -23,11 +23,9 @@ use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; -use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE; use codex_utils_absolute_path::AbsolutePathBuf; -use dunce::canonicalize as normalize_path; use std::io; use toml::Value as TomlValue; use tracing::error; @@ -37,8 +35,8 @@ pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md"; /// Preferred local override for AGENTS.md instructions. pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md"; -/// When both `Config::instructions` and AGENTS.md docs are present, they will -/// be concatenated with the following separator. +/// When both user and project AGENTS.md docs are present, they will be +/// concatenated with the following separator. const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; /// Resolves AGENTS.md files into model-visible user instructions and source @@ -47,11 +45,6 @@ pub struct AgentsMdManager<'a> { config: &'a Config, } -pub(crate) struct LoadedAgentsMd { - pub(crate) contents: String, - pub(crate) path: AbsolutePathBuf, -} - impl<'a> AgentsMdManager<'a> { pub fn new(config: &'a Config) -> Self { Self { config } @@ -81,10 +74,7 @@ impl<'a> AgentsMdManager<'a> { let contents = String::from_utf8_lossy(&data); let trimmed = contents.trim(); if !trimmed.is_empty() { - return Some(LoadedAgentsMd { - contents: trimmed.to_string(), - path, - }); + return Some(LoadedAgentsMd::new_user(trimmed.to_string(), path)); } } None @@ -94,10 +84,10 @@ impl<'a> AgentsMdManager<'a> { /// single model-visible instruction string. pub(crate) async fn user_instructions( &self, - environment: Option<&Environment>, + environment: &Environment, startup_warnings: &mut Vec, - ) -> Option { - let fs = environment?.get_filesystem(); + ) -> Option { + let fs = environment.get_filesystem(); self.user_instructions_with_fs(fs.as_ref(), startup_warnings) .await } @@ -106,22 +96,13 @@ impl<'a> AgentsMdManager<'a> { &self, fs: &dyn ExecutorFileSystem, startup_warnings: &mut Vec, - ) -> Option { + ) -> Option { let agents_md_docs = self.read_agents_md(fs, startup_warnings).await; - let mut output = String::new(); - - if let Some(instructions) = self.config.user_instructions.clone() { - output.push_str(&instructions); - } + let mut loaded = self.config.user_instructions.clone().unwrap_or_default(); match agents_md_docs { - Ok(Some(docs)) => { - if !output.is_empty() { - output.push_str(AGENTS_MD_SEPARATOR); - } - output.push_str(&docs); - } + Ok(Some(docs)) => loaded.entries.extend(docs.entries), Ok(None) => {} Err(e) => { error!("error trying to find AGENTS.md docs: {e:#}"); @@ -129,50 +110,26 @@ impl<'a> AgentsMdManager<'a> { }; if self.config.features.enabled(Feature::ChildAgentsMd) { - if !output.is_empty() { - output.push_str("\n\n"); - } - output.push_str(HIERARCHICAL_AGENTS_MESSAGE); + loaded.entries.push(InstructionEntry { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + provenance: InstructionProvenance::Internal, + }); } - if !output.is_empty() { - Some(output) - } else { - None - } - } - - /// Returns all instruction source files included in the current config. - pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { - let mut global_instruction_warnings = Vec::new(); - let mut paths = Self::load_global_instructions( - LOCAL_FS.as_ref(), - Some(&self.config.codex_home), - &mut global_instruction_warnings, - ) - .await - .map(|loaded| vec![loaded.path]) - .unwrap_or_default(); - match self.agents_md_paths(fs).await { - Ok(agents_md_paths) => paths.extend(agents_md_paths), - Err(err) => { - tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources"); - } - } - paths + (!loaded.is_empty()).then_some(loaded) } /// Attempt to locate and load AGENTS.md documentation. /// - /// On success returns `Ok(Some(contents))` where `contents` is the - /// concatenation of all discovered docs. If no documentation file is found - /// the function returns `Ok(None)`. Unexpected I/O failures bubble up as - /// `Err` so callers can decide how to handle them. + /// On success returns `Ok(Some(loaded))` where `loaded` contains every + /// discovered doc. If no documentation file is found the function returns + /// `Ok(None)`. Unexpected I/O failures bubble up as `Err` so callers can + /// decide how to handle them. async fn read_agents_md( &self, fs: &dyn ExecutorFileSystem, startup_warnings: &mut Vec, - ) -> io::Result> { + ) -> io::Result> { let max_total = self.config.project_doc_max_bytes; if max_total == 0 { @@ -185,7 +142,7 @@ impl<'a> AgentsMdManager<'a> { } let mut remaining: u64 = max_total as u64; - let mut parts: Vec = Vec::new(); + let mut loaded = LoadedAgentsMd::default(); for p in paths { if remaining == 0 { @@ -221,15 +178,18 @@ impl<'a> AgentsMdManager<'a> { let text = String::from_utf8_lossy(&data).to_string(); if !text.trim().is_empty() { - parts.push(text); + loaded.entries.push(InstructionEntry { + contents: text, + provenance: InstructionProvenance::Project(p), + }); remaining = remaining.saturating_sub(data.len() as u64); } } - if parts.is_empty() { + if loaded.is_empty() { Ok(None) } else { - Ok(Some(parts.join("\n\n"))) + Ok(Some(loaded)) } } @@ -246,10 +206,7 @@ impl<'a> AgentsMdManager<'a> { return Ok(Vec::new()); } - let mut dir = self.config.cwd.clone(); - if let Ok(canon) = normalize_path(&dir) { - dir = AbsolutePathBuf::try_from(canon)?; - } + let dir = self.config.cwd.clone(); let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in self.config.config_layer_stack.get_layers( @@ -348,6 +305,114 @@ impl<'a> AgentsMdManager<'a> { } } +/// Model-visible instructions loaded from AGENTS.md files and internal +/// guidance. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LoadedAgentsMd { + /// Ordered instructions and their provenance. + entries: Vec, +} + +impl LoadedAgentsMd { + /// Creates loaded instructions containing one user-level AGENTS.md entry. + pub fn new_user(contents: String, path: AbsolutePathBuf) -> Self { + if contents.trim().is_empty() { + return Self::default(); + } + Self { + entries: vec![InstructionEntry { + contents, + provenance: InstructionProvenance::User(path), + }], + } + } + + /// Creates source-less user instructions for tests. + /// + /// This cannot be gated with `#[cfg(test)]` because integration tests + /// compile `codex-core` as a normal dependency without that configuration. + pub fn from_text_for_testing(contents: impl Into) -> Self { + let contents = contents.into(); + if contents.trim().is_empty() { + return Self::default(); + } + Self { + entries: vec![InstructionEntry { + contents, + provenance: InstructionProvenance::Internal, + }], + } + } + + fn is_empty(&self) -> bool { + self.entries + .iter() + .all(|entry| entry.contents.trim().is_empty()) + } + + /// Returns the concatenated model-visible instruction text. + pub fn text(&self) -> String { + let mut output = String::new(); + let mut previous_provenance: Option<&InstructionProvenance> = None; + for entry in &self.entries { + if let Some(previous_provenance) = previous_provenance { + // The project-doc marker tells the model where workspace-scoped + // instructions begin, so it is only needed on the transition + // from user or internal instructions to project instructions. + let separator = match (previous_provenance, &entry.provenance) { + ( + InstructionProvenance::User(_) | InstructionProvenance::Internal, + InstructionProvenance::Project(_), + ) => AGENTS_MD_SEPARATOR, + _ => "\n\n", + }; + output.push_str(separator); + } + output.push_str(&entry.contents); + previous_provenance = Some(&entry.provenance); + } + output + } + + /// Returns the AGENTS.md files that supplied instruction entries. + pub fn sources(&self) -> impl Iterator { + self.entries + .iter() + .filter_map(|entry| entry.provenance.path()) + } +} + +/// One model-visible instruction and its provenance. +#[derive(Clone, Debug, PartialEq, Eq)] +struct InstructionEntry { + /// Model-visible instruction text. + contents: String, + + /// Origin of the instruction. + provenance: InstructionProvenance, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum InstructionProvenance { + /// User-level instructions, normally loaded from CODEX_HOME. + User(AbsolutePathBuf), + + /// Workspace instructions discovered from project AGENTS.md files. + Project(AbsolutePathBuf), + + /// Instructions without a file source, including internally defined guidance. + Internal, +} + +impl InstructionProvenance { + fn path(&self) -> Option<&AbsolutePathBuf> { + match self { + Self::User(path) | Self::Project(path) => Some(path), + Self::Internal => None, + } + } +} + fn warn_invalid_utf8( path: &AbsolutePathBuf, data: &[u8], diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 2d6a5fa30b..d8298dfd7d 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -5,6 +5,7 @@ use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; use core_test_support::TempDirExt; +use core_test_support::create_directory_symlink; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -16,6 +17,7 @@ async fn get_user_instructions(config: &Config) -> Option { AgentsMdManager::new(config) .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await + .map(|loaded| loaded.text()) } async fn agents_md_paths(config: &Config) -> std::io::Result> { @@ -53,7 +55,12 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) - config.cwd = root.abs(); config.project_doc_max_bytes = limit; - config.user_instructions = instructions.map(ToOwned::to_owned); + config.user_instructions = instructions.map(|text| { + LoadedAgentsMd::new_user( + text.to_owned(), + config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), + ) + }); config } @@ -96,7 +103,12 @@ async fn make_config_with_project_root_markers( config.cwd = root.abs(); config.project_doc_max_bytes = limit; - config.user_instructions = instructions.map(ToOwned::to_owned); + config.user_instructions = instructions.map(|text| { + LoadedAgentsMd::new_user( + text.to_owned(), + config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), + ) + }); config } @@ -115,17 +127,46 @@ async fn no_doc_file_returns_none() { assert!(res.is_none(), "Expected None when AGENTS.md is absent"); } -#[tokio::test] -async fn no_environment_returns_none() { - let tmp = tempfile::tempdir().expect("tempdir"); - let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await; +#[test] +fn empty_loaded_instructions_are_empty() { + let source = + AbsolutePathBuf::from_absolute_path("/tmp/AGENTS.md").expect("absolute source path"); - let mut warnings = Vec::new(); - let res = AgentsMdManager::new(&config) - .user_instructions(/*environment*/ None, &mut warnings) - .await; + assert_eq!( + LoadedAgentsMd::new_user(String::new(), source.clone()), + LoadedAgentsMd::default() + ); + assert_eq!( + LoadedAgentsMd::new_user(" \n\t".to_string(), source), + LoadedAgentsMd::default() + ); + assert_eq!( + LoadedAgentsMd::from_text_for_testing(String::new()), + LoadedAgentsMd::default() + ); + assert_eq!( + LoadedAgentsMd::from_text_for_testing(" \n\t"), + LoadedAgentsMd::default() + ); +} - assert_eq!(res, None); +#[test] +fn loaded_instructions_with_only_empty_or_whitespace_entries_are_empty() { + let empty = LoadedAgentsMd { + entries: vec![InstructionEntry { + contents: String::new(), + provenance: InstructionProvenance::Internal, + }], + }; + let whitespace = LoadedAgentsMd { + entries: vec![InstructionEntry { + contents: " \n\t".to_string(), + provenance: InstructionProvenance::Internal, + }], + }; + + assert!(empty.is_empty()); + assert!(whitespace.is_empty()); } /// Small file within the byte-limit is returned unmodified. @@ -161,8 +202,10 @@ async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() { .await .expect("global doc expected"); - assert_eq!(loaded.contents, "global\u{FFFD} doc"); - assert_eq!(loaded.path, path); + assert_eq!( + loaded, + LoadedAgentsMd::new_user("global\u{FFFD} doc".to_string(), path.clone()) + ); assert_invalid_utf8_warning(&warnings, "Global", path.as_path()); } @@ -177,11 +220,11 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() { let res = AgentsMdManager::new(&config) .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await - .expect("doc expected"); + .expect("doc expected") + .text(); assert_eq!(res, "project\u{FFFD} doc"); - let canonical_path = dunce::canonicalize(&path).expect("canonical doc path"); - assert_invalid_utf8_warning(&warnings, "Project", &canonical_path); + assert_invalid_utf8_warning(&warnings, "Project", config.cwd.join("AGENTS.md").as_path()); } /// Oversize file is truncated to `project_doc_max_bytes`. @@ -272,6 +315,30 @@ async fn merges_existing_instructions_with_agents_md() { assert_eq!(res, expected); } +#[tokio::test] +async fn sourceless_user_instructions_preserve_separator_without_reporting_a_source() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + cfg.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "user instructions".to_string(), + )); + + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let project_agents = cfg.cwd.join("AGENTS.md"); + + assert_eq!( + loaded.text(), + format!("user instructions{AGENTS_MD_SEPARATOR}project doc") + ); + assert_eq!(loaded.sources().collect::>(), vec![&project_agents]); +} + /// If there are existing system instructions but AGENTS.md docs are /// missing we expect the original instructions to be returned unchanged. #[tokio::test] @@ -310,8 +377,32 @@ async fn concatenates_root_and_cwd_docs() { let mut cfg = make_config(&repo, /*limit*/ 4096, /*instructions*/ None).await; cfg.cwd = nested.abs(); - let res = get_user_instructions(&cfg).await.expect("doc expected"); - assert_eq!(res, "root doc\n\ncrate doc"); + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("doc expected"); + let root_agents = repo.path().join("AGENTS.md").abs(); + let crate_agents = cfg.cwd.join("AGENTS.md"); + let expected = LoadedAgentsMd { + entries: vec![ + InstructionEntry { + contents: "root doc".to_string(), + provenance: InstructionProvenance::Project(root_agents.clone()), + }, + InstructionEntry { + contents: "crate doc".to_string(), + provenance: InstructionProvenance::Project(crate_agents.clone()), + }, + ], + }; + + assert_eq!(loaded, expected); + assert_eq!(loaded.text(), "root doc\n\ncrate doc"); + assert_eq!( + loaded.sources().collect::>(), + vec![&root_agents, &crate_agents] + ); } #[tokio::test] @@ -334,14 +425,8 @@ async fn project_root_markers_are_honored_for_agents_discovery() { cfg.cwd = nested.abs(); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - let expected_parent = AbsolutePathBuf::try_from( - dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"), - ) - .expect("absolute parent doc path"); - let expected_child = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"), - ) - .expect("absolute child doc path"); + let expected_parent = root.path().join("AGENTS.md").abs(); + let expected_child = cfg.cwd.join("AGENTS.md"); assert_eq!(discovery.len(), 2); assert_eq!(discovery[0], expected_parent); assert_eq!(discovery[1], expected_child); @@ -350,6 +435,58 @@ async fn project_root_markers_are_honored_for_agents_discovery() { assert_eq!(res, "parent doc\n\nchild doc"); } +#[tokio::test] +async fn agents_md_paths_preserve_symlinked_cwd() { + let tmp = tempfile::tempdir().expect("tempdir"); + let target = tmp.path().join("target"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("AGENTS.md"), "project doc").unwrap(); + + let linked_cwd = tmp.path().join("linked"); + create_directory_symlink(&target, &linked_cwd); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + cfg.cwd = linked_cwd.abs(); + + let discovery = agents_md_paths(&cfg).await.expect("discover paths"); + assert_eq!(discovery, vec![cfg.cwd.join("AGENTS.md")]); + + let res = get_user_instructions(&cfg).await.expect("doc expected"); + assert_eq!(res, "project doc"); +} + +#[tokio::test] +async fn child_agents_message_after_global_instructions_uses_plain_separator() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; + cfg.features.enable(Feature::ChildAgentsMd).unwrap(); + + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); + let expected = LoadedAgentsMd { + entries: vec![ + InstructionEntry { + contents: "global doc".to_string(), + provenance: InstructionProvenance::User(global_agents), + }, + InstructionEntry { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + provenance: InstructionProvenance::Internal, + }, + ], + }; + + assert_eq!(loaded, expected); + assert_eq!( + loaded.text(), + format!("global doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") + ); +} + #[tokio::test] async fn instruction_sources_include_global_before_agents_md_docs() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -360,15 +497,79 @@ async fn instruction_sources_include_global_before_agents_md_docs() { fs::create_dir_all(&cfg.codex_home).unwrap(); fs::write(&global_agents, "global doc").unwrap(); - let sources = AgentsMdManager::new(&cfg) - .instruction_sources(LOCAL_FS.as_ref()) - .await; - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let project_agents = cfg.cwd.join("AGENTS.md"); - assert_eq!(sources, vec![global_agents, project_agents]); + let expected = LoadedAgentsMd { + entries: vec![ + InstructionEntry { + contents: "global doc".to_string(), + provenance: InstructionProvenance::User(global_agents.clone()), + }, + InstructionEntry { + contents: "project doc".to_string(), + provenance: InstructionProvenance::Project(project_agents.clone()), + }, + ], + }; + assert_eq!(loaded, expected); + assert_eq!( + loaded.sources().collect::>(), + vec![&global_agents, &project_agents] + ); + assert_eq!( + loaded.text(), + format!("global doc{AGENTS_MD_SEPARATOR}project doc") + ); +} + +#[tokio::test] +async fn child_agents_message_after_project_docs_is_not_an_instruction_source() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; + cfg.features.enable(Feature::ChildAgentsMd).unwrap(); + let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); + fs::create_dir_all(&cfg.codex_home).unwrap(); + fs::write(&global_agents, "global doc").unwrap(); + + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let project_agents = cfg.cwd.join("AGENTS.md"); + + let expected = LoadedAgentsMd { + entries: vec![ + InstructionEntry { + contents: "global doc".to_string(), + provenance: InstructionProvenance::User(global_agents.clone()), + }, + InstructionEntry { + contents: "project doc".to_string(), + provenance: InstructionProvenance::Project(project_agents.clone()), + }, + InstructionEntry { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + provenance: InstructionProvenance::Internal, + }, + ], + }; + assert_eq!(loaded, expected); + assert_eq!( + loaded.sources().collect::>(), + vec![&global_agents, &project_agents] + ); + assert_eq!( + loaded.text(), + format!("global doc{AGENTS_MD_SEPARATOR}project doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") + ); } /// AGENTS.override.md is preferred over AGENTS.md when both are present. diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 124dfb18c0..ee8ea3619f 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -44,6 +44,7 @@ use codex_api::RawMemory as ApiRawMemory; use codex_api::RealtimeCallClient as ApiRealtimeCallClient; use codex_api::RealtimeSessionConfig as ApiRealtimeSessionConfig; use codex_api::Reasoning; +use codex_api::ReasoningContext; use codex_api::RequestTelemetry; use codex_api::ReqwestTransport; use codex_api::ResponseCreateWsRequest; @@ -100,7 +101,6 @@ use tokio::sync::oneshot::error::TryRecvError; use tokio_tungstenite::tungstenite::Error; use tokio_tungstenite::tungstenite::Message; use tokio_util::sync::CancellationToken; -use tracing::debug; use tracing::instrument; use tracing::trace; use tracing::warn; @@ -610,6 +610,7 @@ impl ModelClient { reasoning: effort.map(|effort| Reasoning { effort: Some(effort), summary: None, + context: None, }), }; @@ -722,12 +723,17 @@ impl ModelClient { ) -> Option { if model_info.supports_reasoning_summaries { Some(Reasoning { - effort: effort.or(model_info.default_reasoning_level), + effort: effort.or_else(|| model_info.default_reasoning_level.clone()), summary: if summary == ReasoningSummaryConfig::None { None } else { Some(summary) }, + // When Responses Lite is disabled, omit context so Responses uses the default, + // which is currently `current_turn`. + context: model_info + .use_responses_lite + .then_some(ReasoningContext::AllTurns), }) } else { None @@ -776,7 +782,7 @@ impl ModelClient { input, tools, tool_choice: "auto".to_string(), - parallel_tool_calls: prompt.parallel_tool_calls, + parallel_tool_calls: prompt.parallel_tool_calls && !model_info.use_responses_lite, reasoning, store: provider.is_azure_responses_endpoint(), stream: true, @@ -967,18 +973,6 @@ impl ModelClientSession { .set_connection_reused(/*connection_reused*/ false); } - pub(crate) async fn send_response_processed(&self, response_id: &str) { - let Some(connection) = self.websocket_session.connection.as_ref() else { - return; - }; - if let Err(err) = connection - .send_response_processed(response_id.to_string()) - .await - { - debug!("failed to send response.processed websocket request: {err}"); - } - } - #[allow(clippy::too_many_arguments)] /// Builds shared Responses API transport options and request-body options. /// @@ -1280,7 +1274,7 @@ impl ModelClientSession { &client_setup.api_provider, prompt, model_info, - effort, + effort.clone(), summary, service_tier.clone(), )?; @@ -1389,7 +1383,7 @@ impl ModelClientSession { &client_setup.api_provider, prompt, model_info, - effort, + effort.clone(), summary, service_tier.clone(), )?; @@ -1608,7 +1602,7 @@ impl ModelClientSession { prompt, model_info, session_telemetry, - effort, + effort.clone(), summary, service_tier.clone(), turn_metadata_header, @@ -1672,9 +1666,7 @@ fn parse_turn_metadata_header(turn_metadata_header: Option<&str>) -> Option, pub collaboration_mode: CollaborationMode, pub session_source: SessionSource, + pub forked_from_thread_id: Option, pub parent_thread_id: Option, pub thread_source: Option, } @@ -548,6 +549,11 @@ impl CodexThread { self.codex.thread_config_snapshot().await } + /// Returns the files that supplied the thread's loaded model instructions. + pub async fn instruction_sources(&self) -> Vec { + self.codex.instruction_sources().await + } + pub async fn config(&self) -> Arc { self.codex.session.get_config().await } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4dc4c34e99..18b5838292 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -573,7 +573,7 @@ async fn drain_to_completed( prompt, &turn_context.model_info, &turn_context.session_telemetry, - turn_context.reasoning_effort, + turn_context.reasoning_effort.clone(), turn_context.reasoning_summary, turn_context.config.service_tier.clone(), turn_metadata_header, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 444b1c3a69..a480c1eba8 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -242,7 +242,7 @@ async fn run_remote_compact_task_inner_impl( &prompt, &turn_context.model_info, CompactConversationRequestSettings { - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: turn_context.reasoning_summary, service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) { None diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index ac64f7f0f2..5f585862dd 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -26,7 +26,6 @@ use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::CompactionTrigger; -use codex_features::Feature; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; @@ -281,7 +280,6 @@ async fn run_remote_compact_task_inner_impl( ); let RemoteCompactionV2Output { compaction_output, - response_id, token_usage, } = compaction_output_result?; if let Some(token_usage) = token_usage { @@ -314,18 +312,11 @@ async fn run_remote_compact_task_inner_impl( sess.emit_turn_item_completed(turn_context, compaction_item) .await; - if turn_context - .features - .enabled(Feature::ResponsesWebsocketResponseProcessed) - { - client_session.send_response_processed(&response_id).await; - } Ok(()) } struct RemoteCompactionV2Output { compaction_output: ResponseItem, - response_id: String, token_usage: Option, } @@ -348,7 +339,7 @@ async fn run_remote_compaction_request_v2( prompt, &turn_context.model_info, &turn_context.session_telemetry, - turn_context.reasoning_effort, + turn_context.reasoning_effort.clone(), turn_context.reasoning_summary, turn_context.config.service_tier.clone(), turn_metadata_header, @@ -409,7 +400,7 @@ async fn collect_compaction_output( let mut output_item_count = 0usize; let mut compaction_count = 0usize; let mut compaction_output = None; - let mut completed_response_id = None; + let mut saw_completed = false; let mut completed_token_usage = None; while let Some(event) = stream.next().await { match event? { @@ -422,12 +413,8 @@ async fn collect_compaction_output( } } } - ResponseEvent::Completed { - response_id, - token_usage, - .. - } => { - completed_response_id = Some(response_id); + ResponseEvent::Completed { token_usage, .. } => { + saw_completed = true; completed_token_usage = token_usage; break; } @@ -435,12 +422,12 @@ async fn collect_compaction_output( } } - let Some(response_id) = completed_response_id else { + if !saw_completed { return Err(CodexErr::Stream( "remote compaction v2 stream closed before response.completed".to_string(), None, )); - }; + } if compaction_count != 1 { return Err(CodexErr::Fatal(format!( @@ -453,7 +440,6 @@ async fn collect_compaction_output( }; Ok(RemoteCompactionV2Output { compaction_output, - response_id, token_usage: completed_token_usage, }) } @@ -804,7 +790,6 @@ mod tests { .expect("compaction should be collected"); assert_eq!(output.compaction_output, compaction); - assert_eq!(output.response_id, "resp-compact"); assert_eq!( output.token_usage, Some(TokenUsage { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index dac8286749..588eb7ac1b 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -208,10 +208,8 @@ async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { #[tokio::test] async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { let codex_home = tempdir()?; - std::fs::write( - codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME), - "\n global instructions \n", - )?; + let global_agents_path = codex_home.abs().join(DEFAULT_AGENTS_MD_FILENAME); + std::fs::write(&global_agents_path, "\n global instructions \n")?; let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), @@ -221,9 +219,14 @@ async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { .await?; let _ = config.features.enable(Feature::MemoryTool); + let user_instructions = config + .user_instructions + .as_ref() + .expect("global instructions expected"); + assert_eq!(user_instructions.text(), "global instructions"); assert_eq!( - config.user_instructions.as_deref(), - Some("global instructions") + user_instructions.sources().collect::>(), + vec![&global_agents_path] ); Ok(()) } @@ -235,7 +238,7 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME), "global instructions", )?; - let global_agents_override_path = codex_home.path().join(LOCAL_AGENTS_MD_FILENAME); + let global_agents_override_path = codex_home.abs().join(LOCAL_AGENTS_MD_FILENAME); std::fs::write(&global_agents_override_path, "local override instructions")?; let config = Config::load_from_base_config_with_overrides( @@ -245,9 +248,14 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R ) .await?; + let user_instructions = config + .user_instructions + .as_ref() + .expect("global override instructions expected"); + assert_eq!(user_instructions.text(), "local override instructions"); assert_eq!( - config.user_instructions.as_deref(), - Some("local override instructions") + user_instructions.sources().collect::>(), + vec![&global_agents_override_path] ); Ok(()) } @@ -476,6 +484,32 @@ async fn load_config_resolves_experimental_request_user_input_enabled() -> std:: Ok(()) } +#[tokio::test] +async fn load_config_resolves_code_mode_config() -> std::io::Result<()> { + let codex_home = tempdir()?; + let config_toml: ConfigToml = toml::from_str( + r#" +[features.code_mode] +enabled = true +excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] +"#, + ) + .expect("TOML deserialization should succeed"); + let config = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.code_mode.excluded_tool_namespaces, + vec!["mcp__codex_apps".to_string(), "multi_agent_v1".to_string()] + ); + assert!(config.features.enabled(Feature::CodeMode)); + Ok(()) +} + #[test] fn rejects_provider_auth_with_env_key() { let err = toml::from_str::( diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 862660ba2e..2d7a41323b 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -225,7 +225,7 @@ impl ConfigDocument { ); mutated |= self.write_optional_value( &["model_reasoning_effort"], - effort.map(|effort| value(effort.to_string())), + effort.as_ref().map(|effort| value(effort.to_string())), ); mutated }), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2c3cd4d6c3..b74f2feefe 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,4 +1,5 @@ use crate::agents_md::AgentsMdManager; +pub use crate::agents_md::LoadedAgentsMd; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::path_utils::normalize_for_native_workdir; @@ -58,6 +59,7 @@ use codex_core_plugins::PluginsConfigInput; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; use codex_features::AppsMcpPathOverrideConfigToml; +use codex_features::CodeModeConfigToml; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -644,7 +646,7 @@ pub struct Config { pub show_raw_agent_reasoning: bool, /// User-provided instructions from AGENTS.md. - pub user_instructions: Option, + pub user_instructions: Option, /// Base instructions override. pub base_instructions: Option, @@ -977,6 +979,9 @@ pub struct Config { /// Whether to register the experimental request_user_input tool. pub experimental_request_user_input_enabled: bool, + /// Configuration for the experimental code-mode tool surface. + pub code_mode: CodeModeConfig, + /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -1029,6 +1034,11 @@ pub struct Config { pub otel: codex_config::types::OtelConfig, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct CodeModeConfig { + pub excluded_tool_namespaces: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MultiAgentV2Config { pub max_concurrent_threads_per_session: usize, @@ -2290,6 +2300,17 @@ fn resolve_experimental_request_user_input_enabled(config_toml: &ConfigToml) -> .is_none_or(|config| config.enabled) } +fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig { + let base = code_mode_toml_config(config_toml.features.as_ref()); + + CodeModeConfig { + excluded_tool_namespaces: base + .and_then(|config| config.excluded_tool_namespaces.as_ref()) + .cloned() + .unwrap_or_default(), + } +} + fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config { let base = multi_agent_v2_toml_config(config_toml.features.as_ref()); let default = MultiAgentV2Config::default(); @@ -2372,6 +2393,13 @@ fn resolve_optional_prompt_text( } } +fn code_mode_toml_config(features: Option<&FeaturesToml>) -> Option<&CodeModeConfigToml> { + match features?.code_mode.as_ref()? { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + } +} + fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiAgentV2ConfigToml> { match features?.multi_agent_v2.as_ref()? { FeatureToml::Enabled(_) => None, @@ -2578,8 +2606,7 @@ impl Config { Some(&codex_home), &mut startup_warnings, ) - .await - .map(|loaded| loaded.contents); + .await; // Destructure ConfigOverrides fully to ensure all overrides are applied. let ConfigOverrides { @@ -3013,6 +3040,7 @@ impl Config { let web_search_config = resolve_web_search_config(&cfg); let experimental_request_user_input_enabled = resolve_experimental_request_user_input_enabled(&cfg); + let code_mode = resolve_code_mode_config(&cfg); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg); let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) { let base = apps_mcp_path_override_toml_config(cfg.features.as_ref()); @@ -3554,6 +3582,7 @@ impl Config { web_search_mode: constrained_web_search_mode.value, web_search_config, experimental_request_user_input_enabled, + code_mode, use_experimental_unified_exec_tool, background_terminal_max_timeout, ghost_snapshot, diff --git a/codex-rs/core/src/context/image_generation_instructions.rs b/codex-rs/core/src/context/image_generation_instructions.rs index 52ce1ca4e9..6986a7c64f 100644 --- a/codex-rs/core/src/context/image_generation_instructions.rs +++ b/codex-rs/core/src/context/image_generation_instructions.rs @@ -1,6 +1,27 @@ use super::ContextualUserFragment; use std::fmt::Display; +/// Maximum size of the extension's model-facing generated-image path hint. +const MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES: usize = 1024; + +/// Returns the extension's model-facing hint, or omits it if the path makes it too large. +pub fn extension_image_generation_output_hint( + image_output_dir: impl Display, + image_output_path: impl Display, +) -> Option { + let hint = image_generation_hint(image_output_dir, image_output_path); + (hint.len() <= MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES).then_some(hint) +} + +fn image_generation_hint( + image_output_dir: impl Display, + image_output_path: impl Display, +) -> String { + format!( + "Generated images are saved to {image_output_dir} as {image_output_path} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it." + ) +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct ImageGenerationInstructions { image_output_dir: String, @@ -30,9 +51,6 @@ impl ContextualUserFragment for ImageGenerationInstructions { } fn body(&self) -> String { - format!( - "Generated images are saved to {} as {} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", - self.image_output_dir, self.image_output_path - ) + image_generation_hint(&self.image_output_dir, &self.image_output_path) } } diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 5115bda6d4..78fc70fd49 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -44,6 +44,7 @@ pub(crate) use environment_context::EnvironmentContext; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; pub(crate) use image_generation_instructions::ImageGenerationInstructions; +pub use image_generation_instructions::extension_image_generation_output_hint; pub use internal_model_context::InternalContextSource; pub use internal_model_context::InternalModelContextFragment; pub use internal_model_context::InvalidInternalContextSource; diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index a44ffbe3ab..cc2f18c4fb 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -22,7 +22,6 @@ use codex_execpolicy::blocking_append_network_rule; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxKind; -use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_shell_command::is_dangerous_command::command_might_be_dangerous; use codex_shell_command::is_safe_command::is_known_safe_command; @@ -121,7 +120,6 @@ pub(crate) enum ExecPolicyCommandOrigin { pub(crate) struct UnmatchedCommandContext<'a> { pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: &'a PermissionProfile, - pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, pub(crate) sandbox_cwd: &'a Path, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) used_complex_parsing: bool, @@ -242,7 +240,6 @@ pub(crate) struct ExecApprovalRequest<'a> { pub(crate) command: &'a [String], pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: PermissionProfile, - pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, pub(crate) sandbox_cwd: &'a Path, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) prefix_rule: Option>, @@ -277,7 +274,6 @@ impl ExecPolicyManager { command, approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, prefix_rule, @@ -298,7 +294,6 @@ impl ExecPolicyManager { UnmatchedCommandContext { approval_policy, permission_profile: &permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, used_complex_parsing, @@ -636,12 +631,12 @@ pub(crate) fn render_decision_for_unmatched_command( let UnmatchedCommandContext { approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, used_complex_parsing, command_origin, } = context; + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); let is_known_safe = match command_origin { ExecPolicyCommandOrigin::Generic => is_known_safe_command(command), #[cfg(windows)] @@ -652,12 +647,8 @@ pub(crate) fn render_decision_for_unmatched_command( // On Windows, ReadOnly sandbox is not a real sandbox, so special-case it // here. - let environment_lacks_sandbox_protections = cfg!(windows) - && profile_is_managed_read_only( - permission_profile, - file_system_sandbox_policy, - sandbox_cwd, - ); + let environment_lacks_sandbox_protections = + cfg!(windows) && profile_is_managed_read_only(permission_profile, sandbox_cwd); if is_known_safe && !used_complex_parsing @@ -751,9 +742,9 @@ pub(crate) fn render_decision_for_unmatched_command( fn profile_is_managed_read_only( permission_profile: &PermissionProfile, - file_system_sandbox_policy: &FileSystemSandboxPolicy, sandbox_cwd: &Path, ) -> bool { + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); matches!(permission_profile, PermissionProfile::Managed { .. }) && matches!( file_system_sandbox_policy.kind, diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 7b0883db2a..5ba8316b1a 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -104,31 +104,6 @@ async fn write_project_trust_config( .await } -fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }]) -} - -fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::workspace_write( - &[], - /*exclude_tmpdir_env_var*/ false, - /*exclude_slash_tmp*/ false, - ) -} - -fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::unrestricted() -} - -fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::external_sandbox() -} - async fn test_config() -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); let config = ConfigBuilder::without_managed_config_for_tests() @@ -665,7 +640,6 @@ async fn evaluates_bash_lc_inner_commands() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -761,7 +735,6 @@ async fn evaluates_heredoc_script_against_prefix_rules() { command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -785,7 +758,6 @@ async fn omits_auto_amendment_for_heredoc_fallback_prompts() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -809,7 +781,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec![ "python3".to_string(), @@ -837,7 +808,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches( ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["python3".to_string()]), }, @@ -862,7 +832,6 @@ async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -893,7 +862,6 @@ EOF"# ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -927,7 +895,6 @@ EOF"# ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -967,7 +934,6 @@ prefix_rule( ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -986,7 +952,6 @@ async fn exec_approval_requirement_prefers_execpolicy_match() { command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1014,7 +979,6 @@ prefix_rule(pattern=["git"], decision="allow") command: vec![git_path, "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1048,7 +1012,6 @@ prefix_rule(pattern=["git"], decision="prompt") command: vec![disallowed_git_path.clone(), "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1075,7 +1038,6 @@ async fn requested_prefix_rule_can_approve_absolute_path_commands() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }, @@ -1098,7 +1060,6 @@ async fn exec_approval_requirement_respects_approval_policy() { command: vec!["rm".to_string()], approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1126,7 +1087,6 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { mcp_elicitations: true, }), permission_profile: &PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, @@ -1137,9 +1097,8 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { } #[test] -fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { +fn unmatched_on_request_uses_permission_profile_file_system_policy_for_escalation_prompts() { let command = vec!["madeup-cmd".to_string()]; - let restricted_file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); assert_eq!( Decision::Prompt, @@ -1147,8 +1106,7 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { &command, UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, - permission_profile: &PermissionProfile::Disabled, - file_system_sandbox_policy: &restricted_file_system_policy, + permission_profile: &PermissionProfile::read_only(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, @@ -1169,7 +1127,6 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, permission_profile: &PermissionProfile::workspace_write(), - file_system_sandbox_policy: &workspace_write_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, @@ -1202,7 +1159,6 @@ fn managed_cwd_write_profile_is_not_read_only() { assert!(!profile_is_managed_read_only( &permission_profile, - &file_system_sandbox_policy, Path::new("/tmp/project") )); } @@ -1233,7 +1189,6 @@ fn managed_unresolvable_write_profile_is_still_read_only() { assert!(profile_is_managed_read_only( &permission_profile, - &file_system_sandbox_policy, Path::new("/tmp/project") )); } @@ -1250,7 +1205,6 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, prefix_rule: None, }, @@ -1273,7 +1227,6 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re command: vec!["echo".to_string(), "hello".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1303,7 +1256,6 @@ async fn exec_approval_requirement_rejects_known_safe_escalation_when_granular_s mcp_elicitations: true, }), permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1329,7 +1281,6 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1365,7 +1316,6 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1403,7 +1353,6 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled( mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1428,7 +1377,6 @@ async fn exec_approval_requirement_falls_back_to_heuristics() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1454,7 +1402,6 @@ async fn empty_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1484,7 +1431,6 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1514,7 +1460,6 @@ async fn request_rule_uses_prefix_rule() { command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -1547,7 +1492,6 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands( command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -1587,7 +1531,6 @@ async fn heuristics_apply_when_other_commands_match_policy() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1663,7 +1606,6 @@ async fn proposed_execpolicy_amendment_is_present_for_single_command_without_pol command: command.clone(), approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1683,7 +1625,6 @@ async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1707,7 +1648,6 @@ async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1737,7 +1677,6 @@ async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scri command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1761,7 +1700,6 @@ async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { command: command.clone(), approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1785,7 +1723,6 @@ async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1816,7 +1753,6 @@ prefix_rule(pattern=["cat"], decision="allow") command: command.clone(), approval_policy, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1848,7 +1784,6 @@ prefix_rule(pattern=["bash"], decision="allow") ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2014,7 +1949,6 @@ async fn dangerous_rm_rf_requires_approval_in_danger_full_access() { command: command.clone(), approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2078,7 +2012,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &sneaky_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, @@ -2103,7 +2036,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, @@ -2124,7 +2056,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, @@ -2146,7 +2077,6 @@ async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() { permission_profile: PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }, - file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2171,7 +2101,6 @@ async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() { permission_profile: PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }, - file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2188,7 +2117,6 @@ struct ExecApprovalRequirementScenario { command: Vec, approval_policy: AskForApproval, permission_profile: PermissionProfile, - file_system_sandbox_policy: FileSystemSandboxPolicy, sandbox_permissions: SandboxPermissions, prefix_rule: Option>, } @@ -2212,7 +2140,6 @@ async fn exec_approval_requirement_for_command( command, approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_permissions, prefix_rule, } = test; @@ -2224,7 +2151,6 @@ async fn exec_approval_requirement_for_command( command: &command, approval_policy, permission_profile, - file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: Path::new("/tmp"), sandbox_permissions, prefix_rule, diff --git a/codex-rs/core/src/exec_policy_windows_tests.rs b/codex-rs/core/src/exec_policy_windows_tests.rs index 1d14d93813..3fba240b41 100644 --- a/codex-rs/core/src/exec_policy_windows_tests.rs +++ b/codex-rs/core/src/exec_policy_windows_tests.rs @@ -15,7 +15,6 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() { ], approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -39,7 +38,6 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -81,7 +79,6 @@ fn unmatched_safe_powershell_words_are_allowed() { UnmatchedCommandContext { approval_policy: AskForApproval::UnlessTrusted, permission_profile: &PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, used_complex_parsing: false, @@ -110,7 +107,6 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 06d1a78931..934e68042f 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -702,7 +702,7 @@ pub(super) async fn run_guardian_review_session( .supported_reasoning_efforts .iter() .any(|effort| effort.effort == codex_protocol::openai_models::ReasoningEffort::Low), - Some(preset.default_reasoning_effort), + Some(preset.default_reasoning_effort.clone()), ); (review_model_id.to_string(), reasoning_effort) } else { @@ -712,7 +712,8 @@ pub(super) async fn run_guardian_review_session( .iter() .any(|preset| preset.effort == codex_protocol::openai_models::ReasoningEffort::Low), turn.reasoning_effort - .or(turn.model_info.default_reasoning_level), + .clone() + .or_else(|| turn.model_info.default_reasoning_level.clone()), ); ( model_override @@ -725,7 +726,7 @@ pub(super) async fn run_guardian_review_session( turn.config.as_ref(), live_network_config.clone(), guardian_model.as_str(), - guardian_reasoning_effort, + guardian_reasoning_effort.clone(), ); let guardian_config = match guardian_config { Ok(config) => config, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index c139a910a7..d81f84590b 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -29,6 +29,7 @@ use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; use tracing::warn; +use crate::LoadedAgentsMd; use crate::codex_delegate::run_codex_thread_interactive; use crate::config::Config; use crate::config::Constrained; @@ -146,7 +147,7 @@ struct GuardianReviewSessionReuseKey { permissions: Permissions, developer_instructions: Option, base_instructions: Option, - user_instructions: Option, + user_instructions: Option, compact_prompt: Option, cwd: AbsolutePathBuf, mcp_servers: Constrained>, @@ -166,7 +167,7 @@ impl GuardianReviewSessionReuseKey { model_context_window: spawn_config.model_context_window, model_auto_compact_token_limit: spawn_config.model_auto_compact_token_limit, model_auto_compact_token_limit_scope: spawn_config.model_auto_compact_token_limit_scope, - model_reasoning_effort: spawn_config.model_reasoning_effort, + model_reasoning_effort: spawn_config.model_reasoning_effort.clone(), model_reasoning_summary: spawn_config.model_reasoning_summary, permissions: spawn_config.permissions.clone(), developer_instructions: spawn_config.developer_instructions.clone(), @@ -657,7 +658,8 @@ async fn run_review_on_session( let guardian_reasoning_effort = if model_info.supports_reasoning_summaries { params .reasoning_effort - .or(model_info.default_reasoning_level) + .clone() + .or_else(|| model_info.default_reasoning_level.clone()) } else { None }; @@ -741,7 +743,7 @@ async fn run_review_on_session( mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: params.model.clone(), - reasoning_effort: params.reasoning_effort, + reasoning_effort: params.reasoning_effort.clone(), developer_instructions: None, }, }), @@ -1106,7 +1108,7 @@ mod tests { async fn test_review_params() -> GuardianReviewSessionParams { let (session, turn) = crate::session::tests::make_session_and_context().await; let model = turn.model_info.slug.clone(); - let reasoning_effort = turn.reasoning_effort; + let reasoning_effort = turn.reasoning_effort.clone(); let reasoning_summary = turn.reasoning_summary; let personality = turn.personality; #[allow(deprecated)] @@ -1115,7 +1117,7 @@ mod tests { turn.config.as_ref(), /*live_network_config*/ None, model.as_str(), - reasoning_effort, + reasoning_effort.clone(), ) .expect("guardian config"); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d3031ad222..b77f96cdb1 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -99,6 +99,7 @@ pub(crate) use skills::manager; pub(crate) use skills::maybe_emit_implicit_skill_invocation; pub(crate) use skills::skills_load_input_from_config; mod stream_events_utils; +pub use stream_events_utils::image_generation_artifact_path; pub mod test_support; mod unified_exec; pub mod windows_sandbox; @@ -130,6 +131,7 @@ pub(crate) mod agents_md; pub use agents_md::AgentsMdManager; pub use agents_md::DEFAULT_AGENTS_MD_FILENAME; pub use agents_md::LOCAL_AGENTS_MD_FILENAME; +pub use agents_md::LoadedAgentsMd; mod rollout; pub(crate) mod safety; mod session_rollout_init_error; diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 85815f8533..c081a2aee6 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -129,7 +129,7 @@ fn save_config_resolved_fields( ) -> anyhow::Result<()> { lock_config.web_search = Some(config.web_search_mode.value()); lock_config.model_provider = Some(config.model_provider_id.clone()); - lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort; + lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort.clone(); lock_config.model_verbosity = config.model_verbosity; lock_config.include_permissions_instructions = Some(config.include_permissions_instructions); lock_config.include_apps_instructions = Some(config.include_apps_instructions); @@ -251,6 +251,14 @@ mod tests { spec.key ); } + assert_eq!( + features.code_mode, + Some(FeatureToml::Enabled( + sc.original_config_do_not_use + .features + .enabled(Feature::CodeMode) + )) + ); let multi_agent_v2 = features .multi_agent_v2 diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3ea33a6bde..9aeca1991b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -507,12 +507,13 @@ impl Codex { let primary_environment = environment_selections.primary_environment(); let mut user_instruction_warnings = Vec::new(); - let user_instructions = AgentsMdManager::new(&config) - .user_instructions( - primary_environment.as_deref(), - &mut user_instruction_warnings, - ) - .await; + let user_instructions = if let Some(primary_environment) = primary_environment { + AgentsMdManager::new(&config) + .user_instructions(primary_environment.as_ref(), &mut user_instruction_warnings) + .await + } else { + None + }; config.startup_warnings.extend(user_instruction_warnings); let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) { @@ -578,7 +579,7 @@ impl Codex { mode: ModeKind::Default, settings: Settings { model: model.clone(), - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }; @@ -800,6 +801,17 @@ impl Codex { state.session_configuration.thread_config_snapshot() } + pub(crate) async fn instruction_sources(&self) -> Vec { + let state = self.session.state.lock().await; + state + .session_configuration + .user_instructions + .as_ref() + .map_or_else(Vec::new, |instructions| { + instructions.sources().cloned().collect() + }) + } + pub(crate) async fn thread_environment_selections(&self) -> Vec { let state = self.session.state.lock().await; state.session_configuration.environments.clone() @@ -3364,6 +3376,9 @@ pub(crate) fn emit_subagent_session_started( session_id: session_id.to_string(), thread_id: thread_id.to_string(), parent_thread_id: parent_thread_id.map(|thread_id| thread_id.to_string()), + forked_from_thread_id: thread_config + .forked_from_thread_id + .map(|thread_id| thread_id.to_string()), product_client_id: client_name.clone(), client_name, client_version, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index de864ebb08..ff4c77aab9 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -74,7 +74,7 @@ pub(super) async fn spawn_review_thread( let auth_manager_for_context = auth_manager.clone(); let provider_for_context = provider.clone(); let session_telemetry_for_context = session_telemetry.clone(); - let reasoning_effort = per_turn_config.model_reasoning_effort; + let reasoning_effort = per_turn_config.model_reasoning_effort.clone(); let reasoning_summary = per_turn_config .model_reasoning_summary .unwrap_or(model_info.default_reasoning_summary); diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 0ca54e55e0..9ee70dada3 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -74,7 +74,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)]; @@ -113,7 +113,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let turn_id = previous_context_item @@ -963,7 +963,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item @@ -1042,7 +1042,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, })) .expect("serialize expected reference context item") @@ -1071,7 +1071,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item @@ -1190,7 +1190,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; @@ -1308,7 +1308,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item @@ -1467,7 +1467,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 07d249613a..9314a743a5 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,5 +1,6 @@ use super::input_queue::InputQueue; use super::*; +use crate::agents_md::LoadedAgentsMd; use crate::config::ConstraintError; use crate::goals::GoalRuntimeState; use crate::skills::SkillError; @@ -54,8 +55,9 @@ pub(crate) struct SessionConfiguration { /// Developer instructions that supplement the base instructions. pub(super) developer_instructions: Option, - /// Model instructions that are appended to the base instructions. - pub(super) user_instructions: Option, + /// Model instructions that are appended to the base instructions and the + /// files that supplied them. + pub(super) user_instructions: Option, /// Personality preference for the model. pub(super) personality: Option, @@ -185,6 +187,7 @@ impl SessionConfiguration { personality: self.personality, collaboration_mode: self.collaboration_mode.clone(), session_source: self.session_source.clone(), + forked_from_thread_id: self.forked_from_thread_id, parent_thread_id: self.parent_thread_id, thread_source: self.thread_source, } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1643574206..a2023ea378 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2559,7 +2559,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let turn_id = previous_context_item @@ -3146,7 +3146,7 @@ async fn set_rate_limits_retains_previous_credits() { let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -3254,7 +3254,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -3785,7 +3785,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -3831,6 +3831,85 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati } } +#[tokio::test] +async fn emit_subagent_session_started_includes_fork_lineage_from_session_configuration() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/analytics-events/events")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let analytics_events_client = AnalyticsEventsClient::new( + auth_manager, + server.uri(), + /*analytics_enabled*/ Some(true), + ); + + let parent_thread_id = ThreadId::new(); + let forked_from_thread_id = ThreadId::new(); + let child_thread_id = ThreadId::new(); + let mut session_configuration = make_session_configuration_for_tests().await; + session_configuration.forked_from_thread_id = Some(forked_from_thread_id); + + emit_subagent_session_started( + &analytics_events_client, + AppServerClientMetadata { + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + }, + SessionId::from(child_thread_id), + child_thread_id, + Some(parent_thread_id), + session_configuration.thread_config_snapshot(), + SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }, + ); + + let event = timeout(Duration::from_secs(1), async { + 'wait_for_event: loop { + if let Some(requests) = server.received_requests().await { + for request in requests { + let payload: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid analytics payload"); + if let Some(event) = payload["events"].as_array().and_then(|events| { + events + .iter() + .find(|event| event["event_type"] == "codex_thread_initialized") + }) { + break 'wait_for_event event.clone(); + } + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("subagent initialization analytics should be emitted"); + + assert_eq!( + event["event_params"]["parent_thread_id"], + parent_thread_id.to_string() + ); + assert_eq!( + event["event_params"]["forked_from_thread_id"], + forked_from_thread_id.to_string() + ); +} + fn turn_environments_for_tests( environment: &Arc, cwd: &codex_utils_absolute_path::AbsolutePathBuf, @@ -4535,7 +4614,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { mode: ModeKind::Default, settings: Settings { model, - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }; @@ -4637,7 +4716,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -4880,7 +4959,7 @@ async fn make_session_with_config_and_rx( mode: ModeKind::Default, settings: Settings { model, - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }; @@ -4985,7 +5064,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( mode: ModeKind::Default, settings: Settings { model, - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }; @@ -5920,7 +5999,7 @@ async fn user_turn_updates_approvals_reviewer() { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: turn_context.model_info.slug.clone(), - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -6727,7 +6806,7 @@ where let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -10789,7 +10868,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique thread settings Arc"); turn_context_mut.permission_profile = PermissionProfile::Disabled; - let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); let command = session.user_shell().derive_exec_args( command_script, turn_context.config.permissions.allow_login_shell, @@ -10801,7 +10879,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { command: &command, approval_policy: turn_context.approval_policy.value(), permission_profile: turn_context.permission_profile(), - file_system_sandbox_policy: &file_system_sandbox_policy, #[allow(deprecated)] sandbox_cwd: turn_context.cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 2765de76fb..4a9316496c 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -698,7 +698,7 @@ async fn track_turn_resolved_config_analytics( permission_profile: turn_context.permission_profile(), #[allow(deprecated)] permission_profile_cwd: turn_context.cwd.to_path_buf(), - reasoning_effort: turn_context.reasoning_effort, + reasoning_effort: turn_context.reasoning_effort.clone(), reasoning_summary: Some(turn_context.reasoning_summary), service_tier: turn_context .config @@ -1799,7 +1799,7 @@ async fn try_run_sampling_request( prompt, &turn_context.model_info, &turn_context.session_telemetry, - turn_context.reasoning_effort, + turn_context.reasoning_effort.clone(), turn_context.reasoning_summary, turn_context.config.service_tier.clone(), turn_metadata_header, @@ -1827,7 +1827,6 @@ async fn try_run_sampling_request( !sess.services.extensions.turn_item_contributors().is_empty(); let mut active_item_is_streaming_to_client = false; let receiving_span = trace_span!("receiving_stream"); - let mut completed_response_id: Option = None; let outcome: CodexResult = loop { let handle_responses = trace_span!( parent: &receiving_span, @@ -2071,9 +2070,9 @@ async fn try_run_sampling_request( sess.services.models_manager.refresh_if_new_etag(etag).await; } ResponseEvent::Completed { - response_id, token_usage, end_turn, + .. } => { flush_assistant_text_segments_all( &sess, @@ -2089,7 +2088,6 @@ async fn try_run_sampling_request( if let Some(false) = end_turn { needs_follow_up = true; } - completed_response_id = Some(response_id); break Ok(SamplingRequestResult { needs_follow_up, last_agent_message, @@ -2213,15 +2211,6 @@ async fn try_run_sampling_request( ) .await; - if sess - .features - .enabled(Feature::ResponsesWebsocketResponseProcessed) - && outcome.is_ok() - && let Some(response_id) = completed_response_id.as_deref() - { - client_session.send_response_processed(response_id).await; - } - drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; if should_emit_token_count { diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index fea422cb4b..66a32f2aa6 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,5 +1,6 @@ use super::*; use crate::SkillLoadOutcome; +use crate::agents_md::LoadedAgentsMd; use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; use codex_core_skills::HostLoadedSkills; @@ -135,7 +136,8 @@ impl TurnContext { pub(crate) fn effective_reasoning_effort(&self) -> Option { if self.model_info.supports_reasoning_summaries { self.reasoning_effort - .or(self.model_info.default_reasoning_level) + .clone() + .or_else(|| self.model_info.default_reasoning_level.clone()) } else { None } @@ -195,28 +197,29 @@ impl TurnContext { let supported_reasoning_levels = model_info .supported_reasoning_levels .iter() - .map(|preset| preset.effort) + .map(|preset| preset.effort.clone()) .collect::>(); - let reasoning_effort = if let Some(current_reasoning_effort) = self.reasoning_effort { + let reasoning_effort = if let Some(current_reasoning_effort) = self.reasoning_effort.clone() + { if supported_reasoning_levels.contains(¤t_reasoning_effort) { Some(current_reasoning_effort) } else { supported_reasoning_levels .get(supported_reasoning_levels.len().saturating_sub(1) / 2) - .copied() - .or(model_info.default_reasoning_level) + .cloned() + .or_else(|| model_info.default_reasoning_level.clone()) } } else { supported_reasoning_levels .get(supported_reasoning_levels.len().saturating_sub(1) / 2) - .copied() - .or(model_info.default_reasoning_level) + .cloned() + .or_else(|| model_info.default_reasoning_level.clone()) }; - config.model_reasoning_effort = reasoning_effort; + config.model_reasoning_effort = reasoning_effort.clone(); let collaboration_mode = self.collaboration_mode.with_updates( Some(model.clone()), - Some(reasoning_effort), + Some(reasoning_effort.clone()), /*developer_instructions*/ None, ); let features = self.features.clone(); @@ -362,7 +365,7 @@ impl TurnContext { collaboration_mode: Some(self.collaboration_mode.clone()), multi_agent_version: Some(self.multi_agent_version), realtime_active: Some(self.realtime_active), - effort: self.reasoning_effort, + effort: self.reasoning_effort.clone(), summary: ReasoningSummaryConfig::Auto, } } @@ -551,7 +554,10 @@ impl Session { app_server_client_name: session_configuration.app_server_client_name.clone(), developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), - user_instructions: session_configuration.user_instructions.clone(), + user_instructions: session_configuration + .user_instructions + .as_ref() + .map(LoadedAgentsMd::text), collaboration_mode: session_configuration.collaboration_mode.clone(), multi_agent_version, personality: session_configuration.personality, diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index d620648179..ba2cc42f29 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -271,7 +271,7 @@ async fn schedule_startup_prewarm_inner( &startup_prompt, &startup_turn_context.model_info, &startup_turn_context.session_telemetry, - startup_turn_context.reasoning_effort, + startup_turn_context.reasoning_effort.clone(), startup_turn_context.reasoning_summary, startup_turn_context.config.service_tier.clone(), startup_turn_metadata_header.as_deref(), diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 20fc6787ac..81beea5be1 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -38,7 +38,8 @@ use tracing::warn; const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images"; -pub(crate) fn image_generation_artifact_path( +/// Returns the host-owned default artifact path for a generated image. +pub fn image_generation_artifact_path( codex_home: &AbsolutePathBuf, session_id: &str, call_id: &str, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 004dc973b7..a50d17f34a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -83,7 +83,7 @@ async fn handle_spawn_agent( sender_thread_id: session.thread_id, prompt: prompt.clone(), model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), + reasoning_effort: args.reasoning_effort.clone().unwrap_or_default(), } .into(), ) @@ -94,14 +94,18 @@ async fn handle_spawn_agent( config.service_tier = Some(service_tier.clone()); } if args.fork_context { - reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?; + reject_full_fork_spawn_overrides( + role_name, + args.model.as_deref(), + args.reasoning_effort.clone(), + )?; } else { apply_requested_spawn_agent_model_overrides( &session, turn.as_ref(), &mut config, args.model.as_deref(), - args.reasoning_effort, + args.reasoning_effort.clone(), ) .await?; apply_role_to_config(&mut config, role_name) @@ -174,7 +178,7 @@ async fn handle_spawn_agent( .unwrap_or_else(|| args.model.clone().unwrap_or_default()); let effective_reasoning_effort = agent_snapshot .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) + .and_then(|snapshot| snapshot.reasoning_effort.clone()) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); session diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 8a4edbff35..8563ed09cb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -224,7 +224,8 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result Result<(), FunctionCallError> { if supported_reasoning_levels .iter() - .any(|preset| preset.effort == requested_reasoning_effort) + .any(|preset| &preset.effort == requested_reasoning_effort) { return Ok(()); } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index e80f63e35d..a15464d9df 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -17,6 +17,7 @@ const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Service tier override for the new agent. Omit unless explicitly requested."; const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5; +const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64; #[derive(Debug, Clone, Default)] pub struct SpawnAgentToolOptions { @@ -762,13 +763,20 @@ fn spawn_agent_models_description(models: &[ModelPreset]) -> String { let model_descriptions = visible_models .into_iter() .map(|model| { - let default_reasoning_effort = model.default_reasoning_effort; + let default_reasoning_effort = &model.default_reasoning_effort; let efforts = model .supported_reasoning_efforts .iter() .map(|preset| { - let effort = preset.effort; - if effort == default_reasoning_effort { + let effort = preset.effort.as_str(); + let effort = match effort + .char_indices() + .nth(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION) + { + Some((index, _)) => &effort[..index], + None => effort, + }; + if &preset.effort == default_reasoning_effort { format!("{effort} (default)") } else { effort.to_string() diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 5f4a66a75c..95be504011 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -186,6 +186,27 @@ fn spawn_agent_tool_caps_visible_model_summaries() { assert!(!description.contains("`sixth-model`")); } +#[test] +fn spawn_agent_tool_caps_reasoning_effort_value_length() { + let mut model = model_preset("visible", /*show_in_picker*/ true); + let custom_effort = ReasoningEffort::Custom( + "é".repeat(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION + 1), + ); + model.default_reasoning_effort = custom_effort.clone(); + model.supported_reasoning_efforts = vec![ReasoningEffortPreset { + effort: custom_effort, + description: "Model-defined".to_string(), + }]; + + assert_eq!( + spawn_agent_models_description(&[model]), + format!( + "Available model overrides (optional; inherited parent model is preferred):\n- `visible-model`: visible description Reasoning efforts: {} (default). Service tiers: priority.", + "é".repeat(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION) + ) + ); +} + #[test] fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index d32bd5dd62..3edbdf19aa 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::LoadedAgentsMd; use crate::ThreadManager; use crate::config::AgentRoleConfig; use crate::config::DEFAULT_AGENT_MAX_DEPTH; @@ -4373,7 +4374,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { expected.base_instructions = Some(base_instructions.text); expected.model = Some(turn.model_info.slug.clone()); expected.model_provider = turn.provider.info().clone(); - expected.model_reasoning_effort = turn.reasoning_effort; + expected.model_reasoning_effort = turn.reasoning_effort.clone(); expected.model_reasoning_summary = Some(turn.reasoning_summary); expected.developer_instructions = turn.developer_instructions.clone(); expected.compact_prompt = turn.compact_prompt.clone(); @@ -4399,7 +4400,10 @@ async fn build_agent_spawn_config_uses_turn_context_values() { async fn build_agent_spawn_config_preserves_base_user_instructions() { let (_session, mut turn) = make_session_and_context().await; let mut base_config = (*turn.config).clone(); - base_config.user_instructions = Some("base-user".to_string()); + base_config.user_instructions = Some(LoadedAgentsMd::new_user( + "base-user".to_string(), + base_config.codex_home.join("AGENTS.md"), + )); turn.user_instructions = Some("resolved-user".to_string()); turn.config = Arc::new(base_config.clone()); let base_instructions = BaseInstructions { @@ -4427,7 +4431,7 @@ async fn build_agent_resume_config_clears_base_instructions() { expected.base_instructions = None; expected.model = Some(turn.model_info.slug.clone()); expected.model_provider = turn.provider.info().clone(); - expected.model_reasoning_effort = turn.reasoning_effort; + expected.model_reasoning_effort = turn.reasoning_effort.clone(); expected.model_reasoning_summary = Some(turn.reasoning_summary); expected.developer_instructions = turn.developer_instructions.clone(); expected.compact_prompt = turn.compact_prompt.clone(); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index d0fb6c515e..7d5b469a45 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -75,7 +75,7 @@ async fn handle_spawn_agent( sender_thread_id: session.thread_id, prompt: prompt.clone(), model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), + reasoning_effort: args.reasoning_effort.clone().unwrap_or_default(), } .into(), ) @@ -86,14 +86,18 @@ async fn handle_spawn_agent( config.service_tier = Some(service_tier.clone()); } if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { - reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?; + reject_full_fork_spawn_overrides( + role_name, + args.model.as_deref(), + args.reasoning_effort.clone(), + )?; } else { apply_requested_spawn_agent_model_overrides( &session, turn.as_ref(), &mut config, args.model.as_deref(), - args.reasoning_effort, + args.reasoning_effort.clone(), ) .await?; apply_role_to_config(&mut config, role_name) @@ -188,7 +192,7 @@ async fn handle_spawn_agent( .unwrap_or_else(|| args.model.clone().unwrap_or_default()); let effective_reasoning_effort = agent_snapshot .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) + .and_then(|snapshot| snapshot.reasoning_effort.clone()) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); session diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 504653cf76..b3c955595a 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -160,7 +160,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result { // We have a successful initial result @@ -284,12 +287,26 @@ impl ToolOrchestrator { None }; if network_policy_decision.is_some() && network_approval_context.is_none() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, }))); } if !tool.escalate_on_failure() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -312,6 +329,13 @@ impl ToolOrchestrator { ExecApprovalRequirement::NeedsApproval { .. } ); if !allow_on_request_network_prompt { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -319,6 +343,13 @@ impl ToolOrchestrator { } } if !unsandboxed_allowed && network_approval_context.is_none() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -400,15 +431,51 @@ impl ToolOrchestrator { }; // Second attempt. + let escalated_attempt_start = Instant::now(); let (retry_result, retry_deferred_network_approval) = Self::run_attempt(tool, req, tool_ctx, &retry_attempt, managed_network_active) .await; - retry_result.map(|output| OrchestratorRunResult { - output, - deferred_network_approval: retry_deferred_network_approval, - }) + let escalated_duration = escalated_attempt_start.elapsed(); + match retry_result { + Ok(output) => { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "escalated", + initial_duration, + Some(escalated_duration), + ); + Ok(OrchestratorRunResult { + output, + deferred_network_approval: retry_deferred_network_approval, + }) + } + Err(err) => { + if let Some(outcome) = sandbox_outcome_from_tool_error(&err) { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + outcome, + initial_duration, + Some(escalated_duration), + ); + } + Err(err) + } + } + } + Err(err) => { + if let Some(outcome) = sandbox_outcome_from_tool_error(&err) { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + outcome, + initial_duration, + /*escalated_duration*/ None, + ); + } + Err(err) } - Err(err) => Err(err), } } @@ -509,6 +576,15 @@ impl ToolOrchestrator { } } +fn sandbox_outcome_from_tool_error(err: &ToolError) -> Option<&'static str> { + match err { + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { .. })) => Some("denied"), + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { .. })) => Some("timed_out"), + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Signal(_))) => Some("signal"), + ToolError::Rejected(_) | ToolError::Codex(_) => None, + } +} + fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String { // Keep approval reason terse and stable for UX/tests, but accept the // output so we can evolve heuristics later without touching call sites. diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 2017718f7d..41221841ef 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -622,7 +622,6 @@ impl EscalationPolicy for CoreShellActionProvider { InterceptedExecPolicyContext { approval_policy: self.approval_policy, permission_profile: self.permission_profile.clone(), - file_system_sandbox_policy: &self.file_system_sandbox_policy, sandbox_cwd: self.sandbox_policy_cwd.as_path(), sandbox_permissions: self.approval_sandbox_permissions, enable_shell_wrapper_parsing: @@ -677,7 +676,6 @@ fn evaluate_intercepted_exec_policy( let InterceptedExecPolicyContext { approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, enable_shell_wrapper_parsing, @@ -705,7 +703,6 @@ fn evaluate_intercepted_exec_policy( crate::exec_policy::UnmatchedCommandContext { approval_policy, permission_profile: &permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, used_complex_parsing, @@ -727,7 +724,6 @@ fn evaluate_intercepted_exec_policy( struct InterceptedExecPolicyContext<'a> { approval_policy: AskForApproval, permission_profile: PermissionProfile, - file_system_sandbox_policy: &'a FileSystemSandboxPolicy, sandbox_cwd: &'a Path, sandbox_permissions: SandboxPermissions, enable_shell_wrapper_parsing: bool, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 6e7a1ced54..ecdf015fd5 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -522,7 +522,6 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, @@ -575,7 +574,6 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, @@ -619,7 +617,6 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, @@ -747,7 +744,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( let argv = ["printf".to_string(), "hello".to_string()]; let approval_policy = AskForApproval::OnRequest; let permission_profile = PermissionProfile::workspace_write(); - let file_system_sandbox_policy = read_only_file_system_sandbox_policy(); let sandbox_cwd = test_sandbox_cwd(); let preapproved = evaluate_intercepted_exec_policy( @@ -757,7 +753,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile: permission_profile.clone(), - file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: super::approval_sandbox_permissions( SandboxPermissions::WithAdditionalPermissions, @@ -773,7 +768,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile, - file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, enable_shell_wrapper_parsing: false, @@ -808,7 +802,6 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 48111fb578..eae0644b83 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -207,7 +207,12 @@ fn build_model_visible_specs_and_registry( if exposure.is_direct() && !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure) { let spec = runtime.spec(); - specs.push(spec_for_model_request(turn_context, exposure, spec)); + specs.push(spec_for_model_request( + turn_context, + exposure, + &tool_name, + spec, + )); } } specs.extend(hosted_specs); @@ -226,12 +231,14 @@ fn build_model_visible_specs_and_registry( fn spec_for_model_request( turn_context: &TurnContext, exposure: ToolExposure, + tool_name: &ToolName, spec: ToolSpec, ) -> ToolSpec { if matches!( turn_context.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly ) && exposure != ToolExposure::DirectModelOnly + && !is_excluded_from_code_mode(turn_context, tool_name) && codex_code_mode::is_code_mode_nested_tool(spec.name()) { codex_tools::augment_tool_spec_for_code_mode(spec) @@ -405,10 +412,19 @@ fn is_hidden_by_code_mode_only( )) } +fn is_excluded_from_code_mode(turn_context: &TurnContext, tool_name: &ToolName) -> bool { + tool_name.namespace.as_ref().is_some_and(|namespace| { + turn_context + .config + .code_mode + .excluded_tool_namespaces + .contains(namespace) + }) +} + fn build_code_mode_executors( turn_context: &TurnContext, executors: &[Arc], - deferred_tools_available: bool, ) -> Vec> { if !matches!( turn_context.tool_mode, @@ -419,6 +435,8 @@ fn build_code_mode_executors( let mut code_mode_nested_tool_specs = Vec::new(); let mut exec_prompt_tool_specs = Vec::new(); + let mut deferred_tools_available = false; + let deferred_tools_guidance_enabled = search_tool_enabled(turn_context); for executor in executors { let exposure = executor.exposure(); if exposure == ToolExposure::DirectModelOnly { @@ -428,9 +446,19 @@ fn build_code_mode_executors( if exposure == ToolExposure::Hidden { continue; } + + if is_excluded_from_code_mode(turn_context, &executor.tool_name()) { + continue; + } + let spec = executor.spec(); - if exposure != ToolExposure::Deferred { + if exposure == ToolExposure::Deferred { + // Only show deferred-tool guidance when supported and an included spec is usable by code mode. + deferred_tools_available |= deferred_tools_guidance_enabled + && !collect_code_mode_exec_prompt_tool_definitions(std::iter::once(&spec)) + .is_empty(); + } else { exec_prompt_tool_specs.push(spec.clone()); } code_mode_nested_tool_specs.push(spec); @@ -839,16 +867,7 @@ fn prepend_code_mode_executors( planned_tools: &mut PlannedTools, ) { let turn_context = context.turn_context; - let deferred_tools_available = search_tool_enabled(turn_context) - && planned_tools - .runtimes() - .iter() - .any(|executor| executor.exposure() == ToolExposure::Deferred); - let code_mode_executors = build_code_mode_executors( - turn_context, - planned_tools.runtimes(), - deferred_tools_available, - ); + let code_mode_executors = build_code_mode_executors(turn_context, planned_tools.runtimes()); planned_tools.runtimes.splice(0..0, code_mode_executors); } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 02a7a21f63..bf5b1c01bb 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -938,6 +938,42 @@ async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() { ); } +#[tokio::test] +async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() { + let plan = probe_with( + |turn| { + set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); + set_feature(turn, Feature::Collab, /*enabled*/ false); + turn.model_info.supports_search_tool = true; + update_config(turn, |config| { + config.code_mode.excluded_tool_namespaces = vec!["excluded".to_string()]; + }); + }, + ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + Some("excluded"), + "lookup", + /*defer_loading*/ true, + )], + ..ToolPlanInputs::default() + }, + ) + .await; + + let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else { + panic!("expected code mode exec tool"); + }; + assert!( + !exec + .description + .contains("Some deferred nested tools may be omitted") + ); + plan.assert_registered_contains(&[ + &ToolName::namespaced("excluded", "lookup").to_string(), + "tool_search", + ]); +} + #[tokio::test] async fn multi_agent_feature_selects_one_agent_tool_family() { let v1 = probe(|turn| { diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 66b8b49a80..2c86deafaf 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1011,7 +1011,6 @@ impl UnifiedExecProcessManager { }; let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self, request.shell_mode.clone()); - let file_system_sandbox_policy = context.turn.file_system_sandbox_policy(); let exec_approval_requirement = context .session .services @@ -1020,7 +1019,6 @@ impl UnifiedExecProcessManager { command: &request.command, approval_policy: context.turn.approval_policy.value(), permission_profile: context.turn.permission_profile(), - file_system_sandbox_policy: &file_system_sandbox_policy, // The process cwd may be model-controlled. Policy resolution // stays anchored to the selected turn environment cwd instead. sandbox_cwd: request.sandbox_cwd.as_path(), diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index be07b803f8..94d72d619b 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -19,6 +19,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; pub use codex_utils_absolute_path::test_support::PathBufExt; pub use codex_utils_absolute_path::test_support::PathExt; use regex_lite::Regex; +use std::path::Path; use std::path::PathBuf; pub mod apps_test_server; @@ -110,6 +111,20 @@ pub fn test_absolute_path(unix_path: &str) -> AbsolutePathBuf { test_absolute_path_with_windows(unix_path, /*windows_path*/ None) } +#[cfg(unix)] +#[allow(clippy::expect_used)] +pub fn create_directory_symlink(source: &Path, link: &Path) { + std::os::unix::fs::symlink(source, link).expect("create directory symlink"); +} + +#[cfg(windows)] +#[allow(clippy::expect_used)] +pub fn create_directory_symlink(source: &Path, link: &Path) { + // Running this test locally may require Windows Developer Mode or an elevated process. + std::os::windows::fs::symlink_dir(source, link) + .expect("create directory symlink; enable Developer Mode or run the test elevated"); +} + pub trait TempDirExt { fn abs(&self) -> AbsolutePathBuf; } diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index eda48a2c0e..75649fa4bc 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -74,7 +74,7 @@ async fn responses_stream_includes_subagent_header_on_review() { let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); config.model = Some(model.clone()); @@ -202,7 +202,7 @@ async fn responses_stream_includes_subagent_header_on_other() { let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); config.model = Some(model.clone()); @@ -318,7 +318,7 @@ async fn responses_respects_model_info_overrides_from_config() { config.model_provider = provider.clone(); config.model_supports_reasoning_summaries = Some(true); config.model_reasoning_summary = Some(ReasoningSummary::Detailed); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = config.model.clone().expect("model configured"); let config = Arc::new(config); diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 276fa4bb51..3fa2136ecb 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -1,5 +1,7 @@ use anyhow::Result; use codex_exec_server::CreateDirectoryOptions; +use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::create_directory_symlink; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -7,6 +9,9 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; +use std::sync::Arc; +use tempfile::TempDir; async fn agents_instructions(mut builder: TestCodexBuilder) -> Result { let server = start_mock_server().await; @@ -139,3 +144,127 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()> { + let server = start_mock_server().await; + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let mut builder = test_codex() + .with_config(|config| { + config.cwd = config.cwd.join("logical-repo/workspace"); + }) + .with_workspace_setup(|cwd, _fs| async move { + // Construct two sibling repositories with the configured cwd as a + // directory symlink from the logical repository into the physical + // repository: + // + // test-root/ + // |-- logical-repo/ + // | |-- .git + // | |-- AGENTS.md ("logical parent doc") + // | `-- workspace ------------> physical-repo/workspace/ + // `-- physical-repo/ + // |-- .git + // |-- AGENTS.md ("physical parent doc") + // `-- workspace/ + // `-- AGENTS.md ("workspace doc") + // + // Discovery should walk the lexical path through logical-repo, + // while opening logical-repo/workspace/AGENTS.md still follows the + // symlink into physical-repo/workspace. + let logical_root = cwd.parent().expect("symlink should have a parent"); + let test_root = logical_root + .parent() + .expect("logical repository should have a parent"); + let physical_root = test_root.join("physical-repo"); + let physical_workspace = physical_root.join("workspace"); + + std::fs::create_dir_all(logical_root.as_path())?; + std::fs::write(logical_root.join(".git"), "")?; + std::fs::write(logical_root.join("AGENTS.md"), "logical parent doc")?; + + std::fs::create_dir_all(physical_workspace.as_path())?; + std::fs::write(physical_root.join(".git"), "")?; + std::fs::write(physical_root.join("AGENTS.md"), "physical parent doc")?; + std::fs::write(physical_workspace.join("AGENTS.md"), "workspace doc")?; + + create_directory_symlink(physical_workspace.as_path(), cwd.as_path()); + Ok(()) + }); + let test = builder.build(&server).await?; + let logical_root = test + .config + .cwd + .parent() + .expect("symlink should have a parent"); + + assert_eq!( + test.codex.instruction_sources().await, + vec![ + logical_root.join("AGENTS.md"), + test.config.cwd.join("AGENTS.md") + ] + ); + + test.submit_turn("hello").await?; + let instructions = resp_mock + .single_request() + .message_input_texts("user") + .into_iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("instructions message"); + assert!(instructions.contains("logical parent doc")); + assert!(instructions.contains("workspace doc")); + assert!(!instructions.contains("physical parent doc")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> { + let server = start_mock_server().await; + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + let home = Arc::new(TempDir::new()?); + let global_agents = home.path().join("AGENTS.md"); + std::fs::write(&global_agents, "global doc")?; + + let mut builder = test_codex() + .with_home(home) + .with_workspace_setup(|cwd, fs| async move { + fs.write_file( + &cwd.join("AGENTS.md"), + b"project doc".to_vec(), + /*sandbox*/ None, + ) + .await?; + Ok::<(), anyhow::Error>(()) + }); + let test = builder.build_with_remote_env(&server).await?; + let project_agents = test.config.cwd.join("AGENTS.md"); + let global_agents = AbsolutePathBuf::try_from(global_agents).expect("absolute path"); + + assert_eq!( + test.codex.instruction_sources().await, + vec![global_agents, project_agents] + ); + + test.submit_turn("hello").await?; + let instructions = resp_mock + .single_request() + .message_input_texts("user") + .into_iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("instructions message"); + assert!(instructions.contains("global doc\n\n--- project-doc ---\n\nproject doc")); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 570f97008f..64ae10d688 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -231,6 +231,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: Some(review_model.to_string()), tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c954a954fb..72e415e2be 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,5 +1,6 @@ use codex_config::ConfigLayerStack; use codex_config::types::AuthCredentialsStoreMode; +use codex_core::LoadedAgentsMd; use codex_core::ModelClient; use codex_core::NewThread; use codex_core::Prompt; @@ -364,7 +365,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { .with_home(codex_home.clone()) .with_config(|config| { // Ensure user instructions are NOT delivered on resume. - config.user_instructions = Some("be nice".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); }); let test = builder .resume(&server, codex_home, session_path.clone()) @@ -875,7 +876,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); config.model = Some(model.clone()); @@ -1180,7 +1181,7 @@ async fn includes_user_instructions_message_in_request() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(|config| { - config.user_instructions = Some("be nice".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); }); let codex = builder .build(&server) @@ -1871,6 +1872,71 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { .and_then(|value| value.as_str()), Some("concise") ); + pretty_assertions::assert_eq!( + request_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")), + None + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls() +-> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = MockServer::start().await; + + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let mut model_catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); + let model = model_catalog + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.4") + .expect("gpt-5.4 exists in bundled models.json"); + model.use_responses_lite = true; + model.supports_parallel_tool_calls = true; + + let TestCodex { codex, .. } = test_codex() + .with_model("gpt-5.4") + .with_config(move |config| { + config.model_catalog = Some(model_catalog); + }) + .build(&server) + .await?; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request_body = resp_mock.single_request().body_json(); + pretty_assertions::assert_eq!( + request_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")) + .and_then(|value| value.as_str()), + Some("all_turns") + ); + pretty_assertions::assert_eq!(request_body.get("parallel_tool_calls"), Some(&json!(false))); Ok(()) } @@ -2212,7 +2278,7 @@ async fn includes_developer_instructions_message_in_request() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(|config| { - config.user_instructions = Some("be nice".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); config.developer_instructions = Some("be useful".to_string()); }); let codex = builder @@ -2331,7 +2397,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); config.model = Some(model.clone()); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 41db88f1ad..b28f820569 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -185,168 +185,6 @@ async fn responses_websocket_streams_without_feature_flag_when_provider_supports server.shutdown().await; } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_sends_response_processed_when_feature_enabled() { - skip_if_no_network!(); - - let server = start_websocket_server(vec![vec![ - vec![ - ev_response_created("resp-prewarm"), - ev_completed("resp-prewarm"), - ], - vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hi"), - ev_completed("resp-1"), - ], - vec![], - ]]) - .await; - - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::ResponsesWebsocketResponseProcessed) - .expect("test config should allow feature update"); - }); - let test = builder - .build_with_websocket_server(&server) - .await - .expect("build websocket codex"); - - test.submit_turn("hello") - .await - .expect("submission should send response.processed after processing"); - - let processed = server - .wait_for_request(/*connection_index*/ 0, /*request_index*/ 2) - .await; - assert_eq!( - processed.body_json(), - json!({ - "type": "response.processed", - "response_id": "resp-1", - }) - ); - - let connection = server.single_connection(); - assert_eq!(connection.len(), 3); - assert_eq!( - connection[1].body_json()["type"].as_str(), - Some("response.create") - ); - - server.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_sends_response_processed_after_remote_compaction_v2() { - skip_if_no_network!(); - - let server = start_websocket_server(vec![vec![ - vec![ - ev_response_created("resp-prewarm"), - ev_completed("resp-prewarm"), - ], - vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hi"), - ev_completed("resp-1"), - ], - vec![], - vec![ - json!({ - "type": "response.output_item.done", - "item": { - "type": "compaction", - "encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY", - } - }), - ev_completed("resp-compact"), - ], - vec![], - ]]) - .await; - - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::RemoteCompactionV2) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::ResponsesWebsocketResponseProcessed) - .expect("test config should allow feature update"); - }); - let test = builder - .build_with_websocket_server(&server) - .await - .expect("build websocket codex"); - - test.submit_turn("hello") - .await - .expect("submission should send response.processed after processing"); - - test.codex - .submit(Op::Compact) - .await - .expect("compact submission should succeed"); - wait_for_event(&test.codex, |msg| matches!(msg, EventMsg::TurnComplete(_))).await; - - let compact_processed = server - .wait_for_request(/*connection_index*/ 0, /*request_index*/ 4) - .await; - assert_eq!( - compact_processed.body_json(), - json!({ - "type": "response.processed", - "response_id": "resp-compact", - }) - ); - - let connection = server.single_connection(); - assert_eq!(connection.len(), 5); - assert_eq!( - connection[3].body_json()["type"].as_str(), - Some("response.create") - ); - - server.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_omits_response_processed_without_feature() { - skip_if_no_network!(); - - let server = start_websocket_server(vec![vec![ - vec![ - ev_response_created("resp-prewarm"), - ev_completed("resp-prewarm"), - ], - vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hi"), - ev_completed("resp-1"), - ], - vec![], - ]]) - .await; - let mut builder = test_codex(); - let test = builder - .build_with_websocket_server(&server) - .await - .expect("build websocket codex"); - - test.submit_turn("hello") - .await - .expect("submission should complete without response.processed"); - - let connection = server.single_connection(); - assert_eq!(connection.len(), 2); - - server.shutdown().await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_reuses_connection_with_per_turn_trace_payloads() { skip_if_no_network!(); @@ -505,7 +343,7 @@ async fn responses_websocket_request_prewarm_reuses_connection() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -559,7 +397,7 @@ async fn responses_websocket_request_prewarm_traces_logical_request() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -604,7 +442,7 @@ async fn responses_websocket_request_prewarm_traces_logical_request() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -696,7 +534,7 @@ async fn responses_websocket_preconnect_is_reused_even_with_header_changes() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -735,7 +573,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -747,7 +585,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -801,7 +639,7 @@ async fn responses_websocket_prewarm_uses_v2_when_provider_supports_websockets() &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -1150,7 +988,7 @@ async fn responses_websocket_emits_reasoning_included_event() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -1224,7 +1062,7 @@ async fn responses_websocket_emits_rate_limit_events() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -1880,7 +1718,7 @@ async fn responses_websocket_v2_after_error_uses_full_create_without_previous_re &prompt_two, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -1968,7 +1806,7 @@ async fn responses_websocket_v2_surfaces_terminal_error_without_close_handshake( &prompt_two, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -2235,7 +2073,7 @@ async fn stream_until_complete_with_request_metadata( prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, service_tier.map(|service_tier| service_tier.request_value().to_string()), turn_metadata_header, diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index e33f964aa5..bc311d4245 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -375,7 +375,11 @@ async fn code_mode_only_restricts_prompt_tools() -> Result<()> { let first_body = resp_mock.single_request().body_json(); assert_eq!( tool_names(&first_body), - vec!["exec".to_string(), "wait".to_string()] + vec![ + "exec".to_string(), + "wait".to_string(), + "web_search".to_string() + ] ); Ok(()) @@ -457,7 +461,12 @@ if (!tool) { let first_body = resp_mock.single_request().body_json(); assert_eq!( tool_names(&first_body), - vec!["exec".to_string(), "wait".to_string()] + vec![ + "exec".to_string(), + "wait".to_string(), + "web_search".to_string(), + "image_generation".to_string() + ] ); let exec_description = first_body @@ -2898,6 +2907,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "escape", "exit", "eval", + "generatedImage", "globalThis", "image", "isFinite", @@ -2953,7 +2963,7 @@ text(JSON.stringify(tool)); parsed, serde_json::json!({ "name": "view_image", - "description": "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk.\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", + "description": "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk.\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file.\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", }) ); @@ -3165,6 +3175,93 @@ text( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_excludes_configured_nested_tool_namespaces() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + let _ = config.features.enable(Feature::CodeMode); + config.code_mode.excluded_tool_namespaces = vec!["excluded".to_string()]; + }); + let base_test = builder.build(&server).await?; + let new_thread = base_test + .thread_manager + .start_thread_with_tools( + base_test.config.clone(), + vec![DynamicToolSpec { + namespace: Some("excluded".to_string()), + name: "lookup".to_string(), + description: "An excluded dynamic tool.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: false, + }], + ) + .await?; + let mut test = base_test; + test.codex = new_thread.thread; + test.session_configured = new_thread.session_configured; + + let first_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call( + "call-1", + "exec", + r#" +text(JSON.stringify({ + excludedType: typeof tools.excluded__lookup, + excludedMetadata: ALL_TOOLS.some(({ name }) => name === "excluded__lookup"), + allowedType: typeof tools.update_plan, + allowedMetadata: ALL_TOOLS.some(({ name }) => name === "update_plan"), +})); +"#, + ), + ev_completed("resp-1"), + ]), + ) + .await; + let second_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("use exec to inspect nested tool namespaces") + .await?; + + assert!( + tool_names(&first_mock.single_request().body_json()).contains(&"excluded".to_string()), + "excluded namespace should remain directly exposed in mixed code mode" + ); + let request = second_mock.single_request(); + let (output, success) = custom_tool_output_body_and_success(&request, "call-1"); + assert_ne!( + success, + Some(false), + "exec configured namespace exclusion failed unexpectedly: {output}" + ); + assert_eq!( + serde_json::from_str::(&output)?, + serde_json::json!({ + "excludedType": "undefined", + "excludedMetadata": false, + "allowedType": "function", + "allowedMetadata": true, + }) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_can_print_content_only_mcp_tool_result_fields() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 22678fc46f..395f3158dd 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -5,6 +5,7 @@ use std::path::Path; use std::path::PathBuf; use anyhow::Result; +use codex_core::LoadedAgentsMd; use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::config_types::ServiceTier; @@ -517,7 +518,9 @@ async fn build_harness_inner( FIXED_CWD, )) .expect("fixed cwd should be absolute"); - config.user_instructions = Some("PARITY_USER_INSTRUCTIONS".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "PARITY_USER_INSTRUCTIONS", + )); config.developer_instructions = Some("PARITY_DEVELOPER_INSTRUCTIONS".to_string()); if settings.service_tier_fast { config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b76a3522ec..a80c43660c 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -58,7 +58,7 @@ fn read_only_user_turn(test: &TestCodex, items: Vec, model: String) - mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model, - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -112,6 +112,7 @@ fn test_model_info( input_modalities, used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -932,6 +933,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index e02e1ffaef..27d92d2774 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -135,7 +135,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -172,7 +172,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -261,7 +261,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -295,7 +295,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -422,7 +422,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: resumed.session_configured.model.clone(), - reasoning_effort: resumed.config.model_reasoning_effort, + reasoning_effort: resumed.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 34fc98b59f..d27c43cfc6 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -370,6 +370,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 7f5fa260cd..d6c7577a7f 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1,11 +1,15 @@ use codex_core::config::Constrained; use codex_features::Feature; +use codex_otel::SessionTelemetry; +use codex_otel::TelemetryAuthMode; +use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -27,6 +31,7 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use std::sync::Mutex; +use std::time::Duration; use tracing::Level; use tracing_test::traced_test; @@ -1145,6 +1150,70 @@ fn tool_decision_assertion<'a>( } } +fn sandbox_outcome_assertion<'a>( + call_id: &'a str, + expected_outcome: &'a str, +) -> impl Fn(&[&str]) -> Result<(), String> + 'a { + let call_id = call_id.to_string(); + let expected_outcome = expected_outcome.to_string(); + + move |lines: &[&str]| { + let line = lines + .iter() + .find(|line| { + line.contains("codex.sandbox_outcome") + && line.contains(&format!("call_id={call_id}")) + }) + .ok_or_else(|| format!("missing codex.sandbox_outcome event for {call_id}"))?; + + let lower = line.to_lowercase(); + if !lower.contains("tool_name=shell_command") { + return Err("missing tool_name for shell_command".to_string()); + } + if !lower.contains(&format!("outcome={expected_outcome}")) { + return Err(format!("unexpected sandbox outcome for {call_id}")); + } + if !lower.contains("initial_duration_ms=12") { + return Err("missing initial_duration_ms field".to_string()); + } + if !lower.contains("escalated_duration_ms=34") { + return Err("missing escalated_duration_ms field".to_string()); + } + + Ok(()) + } +} + +#[test] +#[traced_test] +fn sandbox_outcome_event_records_outcome() { + let telemetry = SessionTelemetry::new( + ThreadId::new(), + "gpt-5.5", + "gpt-5.5", + /*account_id*/ None, + /*account_email*/ None, + Some(TelemetryAuthMode::ApiKey), + "Codex_Desktop".to_string(), + /*log_user_prompts*/ false, + "tty".to_string(), + SessionSource::Cli, + ); + + telemetry.sandbox_outcome( + "shell_command", + "sandbox-outcome-call", + "escalated", + Duration::from_millis(/*millis*/ 12), + Some(Duration::from_millis(/*millis*/ 34)), + ); + + logs_assert(sandbox_outcome_assertion( + "sandbox-outcome-call", + "escalated", + )); +} + #[tokio::test] #[traced_test] async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 231a6d826f..bf411d9cd1 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -79,7 +79,7 @@ fn read_only_text_turn_with_personality( mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model, - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -592,6 +592,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -705,6 +706,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index b9afa13852..ea646a4dbc 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use codex_core::LoadedAgentsMd; use codex_core::shell::default_user_shell; use codex_features::Feature; use codex_prompts::APPLY_PATCH_TOOL_INSTRUCTIONS; @@ -122,7 +123,9 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config.model = Some("gpt-5.2".to_string()); // Keep tool expectations stable when the default web_search mode changes. config @@ -237,7 +240,9 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -319,7 +324,9 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -417,7 +424,9 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -709,7 +718,9 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -844,7 +855,9 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -857,7 +870,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let default_approval_policy = config.permissions.approval_policy.value(); let default_sandbox_policy = &config.legacy_sandbox_policy(); let default_model = session_configured.model; - let default_effort = config.model_reasoning_effort; + let default_effort = config.model_reasoning_effort.clone(); let default_summary = config.model_reasoning_summary; codex @@ -879,7 +892,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: default_model.clone(), - reasoning_effort: default_effort, + reasoning_effort: default_effort.clone(), developer_instructions: None, }, }), @@ -985,7 +998,9 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -998,7 +1013,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let default_approval_policy = config.permissions.approval_policy.value(); let default_sandbox_policy = &config.legacy_sandbox_policy(); let default_model = session_configured.model; - let default_effort = config.model_reasoning_effort; + let default_effort = config.model_reasoning_effort.clone(); let default_summary = config.model_reasoning_summary; codex diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index dc506bc474..bad7e1ab5a 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::LoadedAgentsMd; use codex_core::build_prompt_input; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -21,7 +22,9 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { }) .build() .await?; - config.user_instructions = Some("Project-specific test instructions".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "Project-specific test instructions", + )); let input = build_prompt_input( config, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index fd6e01c075..0ed39216d0 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -318,7 +318,7 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<()> { +async fn remote_models_long_model_slug_is_sent_with_custom_reasoning() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -331,15 +331,16 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( /*priority*/ 1_000, TruncationPolicyConfig::bytes(/*limit*/ 10_000), ); - remote_model.default_reasoning_level = Some(ReasoningEffort::High); + let custom_reasoning_effort = ReasoningEffort::Custom("max".to_string()); + remote_model.default_reasoning_level = Some(custom_reasoning_effort.clone()); remote_model.supported_reasoning_levels = vec![ ReasoningEffortPreset { effort: ReasoningEffort::Medium, description: ReasoningEffort::Medium.to_string(), }, ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: ReasoningEffort::High.to_string(), + effort: custom_reasoning_effort.clone(), + description: custom_reasoning_effort.to_string(), }, ]; remote_model.supports_reasoning_summaries = true; @@ -393,7 +394,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( .and_then(|reasoning| reasoning.get("summary")) .and_then(|value| value.as_str()); assert_eq!(body["model"].as_str(), Some(requested_model)); - assert_eq!(reasoning_effort, Some("high")); + assert_eq!(reasoning_effort, Some("max")); assert_eq!(reasoning_summary, Some("detailed")); Ok(()) @@ -477,6 +478,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -729,6 +731,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -1215,6 +1218,7 @@ fn test_remote_model_with_policy( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 54b046e66c..422489a86a 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -41,7 +41,7 @@ fn resume_history( collaboration_mode: None, multi_agent_version: None, realtime_active: None, - effort: config.model_reasoning_effort, + effort: config.model_reasoning_effort.clone(), summary: config .model_reasoning_summary .unwrap_or(ReasoningSummary::Auto), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 36ea7d4b2c..552c4ed529 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1349,6 +1349,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re input_modalities: vec![InputModality::Text], used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 3d4cb93b9c..532f4821d9 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -55,7 +55,7 @@ fn disabled_text_turn(test: &TestCodex, text: &str) -> Op { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: REQUESTED_MODEL.to_string(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 3b581c9051..ba29b4f00c 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -60,6 +60,7 @@ fn test_model_info( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 2e34475387..19758592c3 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1355,6 +1355,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an input_modalities: vec![InputModality::Text], used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index bf2def2407..e3eb814f36 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -68,7 +68,7 @@ Each response yields: - `data` - ordered list of models. A model includes: - `id`, `model`, `displayName`, `description` - `supportedReasoningEfforts` - array of objects with: - - `reasoningEffort` - one of `none|minimal|low|medium|high|xhigh` + - `reasoningEffort` - a string value advertised by the model; common values are `none|minimal|low|medium|high|xhigh` - `description` - human-friendly label for the effort - `defaultReasoningEffort` - suggested effort for the UI - `inputModalities` - accepted input types for the model 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 c3190b8740..00a9f957f1 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -446,7 +446,8 @@ fn config_summary_entries( "reasoning effort", config .model_reasoning_effort - .map(|effort| effort.to_string()) + .as_ref() + .map(std::string::ToString::to_string) .unwrap_or_else(|| "none".to_string()), )); entries.push(( diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 406197d720..797e168dc5 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -694,7 +694,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let default_cwd = config.cwd.to_path_buf(); let default_approval_policy = config.permissions.approval_policy.value(); - let default_effort = config.model_reasoning_effort; + let default_effort = config.model_reasoning_effort.clone(); let (initial_operation, prompt_summary) = match (command.as_ref(), prompt, images) { (Some(ExecCommand::Review(review_cli)), _, _) => { @@ -1169,7 +1169,7 @@ fn session_configured_from_thread_start_response( config.permissions.effective_permission_profile(), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), ) } @@ -1192,7 +1192,7 @@ fn session_configured_from_thread_resume_response( config.permissions.effective_permission_profile(), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), ) } diff --git a/codex-rs/exec/tests/suite/agents_md.rs b/codex-rs/exec/tests/suite/agents_md.rs new file mode 100644 index 0000000000..2891721ae6 --- /dev/null +++ b/codex-rs/exec/tests/suite/agents_md.rs @@ -0,0 +1,74 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use core_test_support::responses; +use core_test_support::test_codex_exec::test_codex_exec; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_includes_workspace_agents_md_in_request() -> anyhow::Result<()> { + let test = test_codex_exec(); + std::fs::write(test.cwd_path().join("AGENTS.md"), "workspace instructions")?; + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("tell me something") + .assert() + .success(); + + let user_messages = response_mock.single_request().message_input_texts("user"); + assert!( + user_messages + .iter() + .any(|text| text.contains("workspace instructions")), + "request should include workspace AGENTS.md instructions: {user_messages:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_prefers_workspace_agents_override_md() -> anyhow::Result<()> { + let test = test_codex_exec(); + std::fs::write(test.cwd_path().join("AGENTS.md"), "base instructions")?; + std::fs::write( + test.cwd_path().join("AGENTS.override.md"), + "override instructions", + )?; + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("tell me something") + .assert() + .success(); + + let user_messages = response_mock.single_request().message_input_texts("user"); + assert!( + user_messages + .iter() + .any(|text| text.contains("override instructions")), + "request should include AGENTS.override.md instructions: {user_messages:?}" + ); + assert!( + user_messages + .iter() + .all(|text| !text.contains("base instructions")), + "request should exclude shadowed AGENTS.md instructions: {user_messages:?}" + ); + + Ok(()) +} diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index 6f86856327..e7452cc17a 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -1,5 +1,6 @@ // Aggregates all former standalone integration tests as modules. mod add_dir; +mod agents_md; mod apply_patch; mod approval_policy; mod auth_env; diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml index 995f1786bc..3f22bd3fff 100644 --- a/codex-rs/ext/image-generation/Cargo.toml +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -23,6 +23,7 @@ codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } codex-protocol = { workspace = true } codex-tools = { workspace = true } +codex-utils-absolute-path = { workspace = true } http = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/ext/image-generation/imagegen_description.md b/codex-rs/ext/image-generation/imagegen_description.md index 7ae6ecb5fa..1e5a46390f 100644 --- a/codex-rs/ext/image-generation/imagegen_description.md +++ b/codex-rs/ext/image-generation/imagegen_description.md @@ -4,6 +4,7 @@ The `image_gen.imagegen` tool enables image generation from descriptions and edi - The user wants to modify an attached or previously generated image with specific changes, including adding or removing elements, altering colors, improving quality/resolution, or transforming the style (e.g., cartoon, oil painting). Guidelines: +- In code mode, pass the result to `generatedImage(result)`. - Set `action` to `generate` when the user asks for a brand new image. - Set `action` to `edit` when the user asks to modify an existing image from the conversation history. - Directly generate the image without reconfirmation or clarification. diff --git a/codex-rs/ext/image-generation/src/extension.rs b/codex-rs/ext/image-generation/src/extension.rs index f7a9f2406f..6f6a7c7b1b 100644 --- a/codex-rs/ext/image-generation/src/extension.rs +++ b/codex-rs/ext/image-generation/src/extension.rs @@ -13,6 +13,7 @@ use codex_features::Feature; use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; +use codex_utils_absolute_path::AbsolutePathBuf; use crate::backend::CodexImagesBackend; use crate::tool::ImageGenerationTool; @@ -26,6 +27,7 @@ struct ImageGenerationExtension { struct ImageGenerationExtensionConfig { enabled: bool, provider: ModelProviderInfo, + codex_home: AbsolutePathBuf, } impl From<&Config> for ImageGenerationExtensionConfig { @@ -35,6 +37,7 @@ impl From<&Config> for ImageGenerationExtensionConfig { enabled: config.features.enabled(Feature::ImageGenExt) && config.model_provider.is_openai(), provider: config.model_provider.clone(), + codex_home: config.codex_home.clone(), } } } @@ -76,9 +79,14 @@ impl ToolContributor for ImageGenerationExtension { return Vec::new(); } - vec![Arc::new(ImageGenerationTool::new(CodexImagesBackend::new( - create_model_provider(config.provider.clone(), Some(self.auth_manager.clone())), - )))] + vec![Arc::new(ImageGenerationTool::new( + CodexImagesBackend::new(create_model_provider( + config.provider.clone(), + Some(self.auth_manager.clone()), + )), + config.codex_home.clone(), + thread_store.level_id().to_string(), + ))] } } diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index 44f2d80ebe..6a974e2d51 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -3,6 +3,7 @@ use codex_api::ImageEditRequest; use codex_api::ImageGenerationRequest; use codex_api::ImageQuality; use codex_api::ImageUrl; +use codex_core::context::extension_image_generation_output_hint; use codex_extension_api::ToolOutput; use codex_extension_api::ToolPayload; use codex_extension_api::ToolSpec; @@ -54,9 +55,58 @@ fn generate_uses_fixed_request_defaults() { } #[test] -fn generated_output_returns_image_input() { +fn generated_output_returns_image_input_and_output_hint() { + let output_hint = + extension_image_generation_output_hint("/tmp", "/tmp/call-1.png").expect("hint should fit"); let output = GeneratedImageOutput { result: RESULT.to_string(), + output_hint: Some(output_hint.clone()), + }; + + let ResponseInputItem::FunctionCallOutput { + output: response_output, + .. + } = output.to_response_item("call-1", &function_payload()) + else { + panic!("imagegen should return function tool output"); + }; + let FunctionCallOutputBody::ContentItems(content_items) = response_output.body else { + panic!("imagegen output should contain generated image bytes"); + }; + assert_eq!( + content_items, + vec![ + FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{RESULT}"), + detail: Some(DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { text: output_hint }, + ] + ); +} + +#[test] +fn generated_output_returns_generated_image_helper_input_in_code_mode() { + let output = GeneratedImageOutput { + result: RESULT.to_string(), + output_hint: Some("generated image save hint".to_string()), + }; + + assert_eq!( + output.code_mode_result(&function_payload()), + serde_json::json!({ + "image_url": format!("data:image/png;base64,{RESULT}"), + "output_hint": "generated image save hint", + }) + ); +} + +#[test] +fn generated_output_omits_oversized_output_hint() { + let long_path = "x".repeat(1024); + let output = GeneratedImageOutput { + result: RESULT.to_string(), + output_hint: extension_image_generation_output_hint("/tmp", long_path), }; let ResponseInputItem::FunctionCallOutput { diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index e4e8e13135..9c73e96b23 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -3,6 +3,8 @@ use codex_api::ImageEditRequest; use codex_api::ImageGenerationRequest; use codex_api::ImageQuality; use codex_api::ImageUrl; +use codex_core::context::extension_image_generation_output_hint; +use codex_core::image_generation_artifact_path; use codex_extension_api::ExtensionTurnItem; use codex_extension_api::FunctionCallError; use codex_extension_api::ToolCall; @@ -25,6 +27,7 @@ use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; use codex_tools::ToolExposure; use codex_tools::default_namespace_description; +use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use schemars::r#gen::SchemaSettings; use serde::Deserialize; @@ -42,12 +45,22 @@ const IMAGEGEN_DESCRIPTION: &str = include_str!("../imagegen_description.md"); #[derive(Clone)] pub(crate) struct ImageGenerationTool { backend: CodexImagesBackend, + codex_home: AbsolutePathBuf, + thread_id: String, } impl ImageGenerationTool { /// Creates an image-generation tool backed by an image API executor. - pub(crate) fn new(backend: CodexImagesBackend) -> Self { - Self { backend } + pub(crate) fn new( + backend: CodexImagesBackend, + codex_home: AbsolutePathBuf, + thread_id: String, + ) -> Self { + Self { + backend, + codex_home, + thread_id, + } } } @@ -116,7 +129,17 @@ impl ToolExecutor for ImageGenerationTool { saved_path: None, })) .await; - Ok(Box::new(GeneratedImageOutput { result })) + let output_path = + image_generation_artifact_path(&self.codex_home, &self.thread_id, &call.call_id); + let output_dir = output_path + .parent() + .unwrap_or_else(|| self.codex_home.clone()); + let output_hint = + extension_image_generation_output_hint(output_dir.display(), output_path.display()); + Ok(Box::new(GeneratedImageOutput { + result, + output_hint, + })) } } @@ -297,6 +320,7 @@ fn imagegen_tool_spec() -> ToolSpec { struct GeneratedImageOutput { result: String, + output_hint: Option, } impl ToolOutput for GeneratedImageOutput { @@ -310,12 +334,32 @@ impl ToolOutput for GeneratedImageOutput { true } - /// Returns generated bytes for model follow-up. + /// Returns the object consumed by the code-mode `generatedImage()` helper. + fn code_mode_result(&self, _payload: &ToolPayload) -> Value { + let mut result = Map::from_iter([( + "image_url".to_string(), + Value::String(format!("data:image/png;base64,{}", self.result)), + )]); + if let Some(output_hint) = &self.output_hint { + result.insert( + "output_hint".to_string(), + Value::String(output_hint.clone()), + ); + } + Value::Object(result) + } + + /// Returns generated bytes and persisted-artifact context for model follow-up. fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { - let content = vec![FunctionCallOutputContentItem::InputImage { + let mut content = vec![FunctionCallOutputContentItem::InputImage { image_url: format!("data:image/png;base64,{}", self.result), detail: Some(DEFAULT_IMAGE_DETAIL), }]; + if let Some(output_hint) = &self.output_hint { + content.push(FunctionCallOutputContentItem::InputText { + text: output_hint.clone(), + }); + } ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output: FunctionCallOutputPayload { diff --git a/codex-rs/external-agent-migration/src/lib.rs b/codex-rs/external-agent-migration/src/lib.rs index d7b8801796..6ee0b38a24 100644 --- a/codex-rs/external-agent-migration/src/lib.rs +++ b/codex-rs/external-agent-migration/src/lib.rs @@ -1451,6 +1451,49 @@ mod tests { ); } + #[test] + fn mcp_migration_prefers_command_transport_for_mixed_server_config() { + let root = tempfile::TempDir::new().expect("tempdir"); + fs::write( + root.path().join(".mcp.json"), + r#"{ + "mcpServers": { + "mixedTransport": { + "command": "mcp-remote-proxy", + "args": [ + "https://example.com/mixed-transport", + "--transport", + "http" + ], + "url": "https://example.com/mixed-transport" + } + } + }"#, + ) + .expect("write mcp"); + + assert_eq!( + build_mcp_config_from_external( + root.path(), + /*external_agent_home*/ None, + /*settings*/ None, + ) + .unwrap(), + toml::from_str( + r#" +[mcp_servers.mixedTransport] +command = "mcp-remote-proxy" +args = [ + "https://example.com/mixed-transport", + "--transport", + "http", +] +"# + ) + .unwrap() + ); + } + #[test] fn mcp_migration_skips_unsupported_transports() { let root = tempfile::TempDir::new().expect("tempdir"); diff --git a/codex-rs/external-agent-sessions/src/detect.rs b/codex-rs/external-agent-sessions/src/detect.rs index 27613eed18..f42c90766b 100644 --- a/codex-rs/external-agent-sessions/src/detect.rs +++ b/codex-rs/external-agent-sessions/src/detect.rs @@ -1,7 +1,10 @@ use crate::ExternalAgentSessionMigration; use crate::ledger::load_import_ledger; +use crate::ledger::save_import_ledger; use crate::now_unix_seconds; use crate::summarize_session; +use std::cmp::Reverse; +use std::collections::BinaryHeap; use std::fs; use std::io; use std::path::Path; @@ -10,12 +13,6 @@ use std::time::Duration; const SESSION_IMPORT_MAX_COUNT: usize = 50; const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60); -#[derive(Debug)] -struct SessionCandidate { - latest_timestamp: i64, - migration: ExternalAgentSessionMigration, -} - pub fn detect_recent_sessions( external_agent_home: &Path, codex_home: &Path, @@ -26,8 +23,9 @@ pub fn detect_recent_sessions( } let now = now_unix_seconds(); - let ledger = load_import_ledger(codex_home)?; - let mut candidates = Vec::new(); + let mut ledger = load_import_ledger(codex_home)?; + let source_states = ledger.source_states(); + let mut file_candidates = BinaryHeap::with_capacity(SESSION_IMPORT_MAX_COUNT + 1); for project_entry in fs::read_dir(projects_root)? { let Ok(project_entry) = project_entry else { continue; @@ -47,44 +45,67 @@ pub fn detect_recent_sessions( if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { continue; } - let Ok(Some(summary)) = summarize_session(&path) else { + let Ok(metadata) = entry.metadata() else { continue; }; - let Ok(has_been_imported) = ledger.contains_current_source(&path) else { + let Ok(modified_at) = metadata.modified() else { continue; }; - if has_been_imported { + let Ok(modified_at) = modified_at.duration_since(std::time::UNIX_EPOCH) else { + continue; + }; + if (modified_at.as_secs() as i64) + < now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64) + { continue; } - if !is_recent_enough(now, summary.latest_timestamp) { + let Ok(modified_at_nanos) = i64::try_from(modified_at.as_nanos()) else { + continue; + }; + let Ok(source_path) = fs::canonicalize(&path) else { + continue; + }; + if let Some(state) = source_states.get(source_path.as_path()) + && (state.source_modified_at == Some(modified_at_nanos) + || state.source_modified_at.is_none() + && modified_at.as_secs() as i64 <= state.imported_at) + { continue; } - let migration = summary.migration; - if !migration.cwd.is_dir() { - continue; + file_candidates.push((Reverse(modified_at_nanos), path)); + if file_candidates.len() > SESSION_IMPORT_MAX_COUNT { + file_candidates.pop(); } - candidates.push(SessionCandidate { - latest_timestamp: summary.latest_timestamp, - migration, - }); } } - candidates.sort_by(|left, right| { - right - .latest_timestamp - .cmp(&left.latest_timestamp) - .then_with(|| left.migration.path.cmp(&right.migration.path)) - }); - candidates.truncate(SESSION_IMPORT_MAX_COUNT); - Ok(candidates - .into_iter() - .map(|candidate| candidate.migration) - .collect()) -} + drop(source_states); + let file_candidates = file_candidates.into_sorted_vec(); + let mut migrations = Vec::new(); + let mut ledger_changed = false; + for (modified_at, path) in file_candidates { + match ledger.refresh_current_source(&path, modified_at.0) { + Ok(false) => {} + Ok(true) => { + ledger_changed = true; + continue; + } + Err(_) => continue, + } + let Ok(Some(summary)) = summarize_session(&path) else { + continue; + }; + let migration = summary.migration; + if !migration.cwd.is_dir() { + continue; + } + migrations.push(migration); + } + if ledger_changed { + save_import_ledger(codex_home, &ledger)?; + } -fn is_recent_enough(now: i64, latest_timestamp: i64) -> bool { - latest_timestamp >= now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64) + Ok(migrations) } #[cfg(test)] @@ -93,7 +114,10 @@ mod tests { use crate::ledger::record_imported_session; use codex_protocol::ThreadId; use serde_json::Value as JsonValue; + use std::fs::FileTimes; + use std::fs::OpenOptions; use std::path::Path; + use std::time::SystemTime; use tempfile::TempDir; #[test] @@ -207,11 +231,11 @@ mod tests { } #[test] - fn ignores_old_sessions() { + fn uses_file_modification_time_for_recency() { let root = TempDir::new().expect("tempdir"); let external_agent_home = root.path().join(".external"); let project_root = root.path().join("repo"); - write_session( + let session_path = write_session( &external_agent_home, &project_root, "session.jsonl", @@ -223,6 +247,34 @@ mod tests { )], ); + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!( + sessions, + vec![ExternalAgentSessionMigration { + path: session_path, + cwd: project_root, + title: Some("hello".to_string()), + }] + ); + } + + #[test] + fn ignores_sessions_with_old_file_modification_time() { + let root = TempDir::new().expect("tempdir"); + let external_agent_home = root.path().join(".external"); + let project_root = root.path().join("repo"); + let session_path = write_session( + &external_agent_home, + &project_root, + "session.jsonl", + &[record("user", "hello", &project_root)], + ); + set_modified_at( + &session_path, + SystemTime::UNIX_EPOCH + Duration::from_secs(/*secs*/ 1), + ); + assert!( detect_recent_sessions(&external_agent_home, root.path()) .expect("detect") @@ -230,6 +282,84 @@ mod tests { ); } + #[test] + fn detects_sessions_in_batches() { + let root = TempDir::new().expect("tempdir"); + let external_agent_home = root.path().join(".external"); + let project_root = root.path().join("repo"); + let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let modified_at = SystemTime::now(); + let mut expected = Vec::new(); + for index in 0..=SESSION_IMPORT_MAX_COUNT { + let file_name = format!("{index:02}-session.jsonl"); + let title = format!("session {index}"); + let path = write_session( + &external_agent_home, + &project_root, + &file_name, + &[record_at("user", &title, &project_root, ×tamp)], + ); + set_modified_at( + &path, + modified_at - Duration::from_secs(/*secs*/ index as u64), + ); + expected.push(ExternalAgentSessionMigration { + path, + cwd: project_root.clone(), + title: Some(title), + }); + } + let oldest_session = expected.pop().expect("oldest session"); + let mut all_sessions = expected.clone(); + all_sessions.push(oldest_session.clone()); + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!(sessions, expected); + for session in sessions { + record_imported_session(root.path(), &session.path, ThreadId::new()) + .expect("record import"); + } + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!(sessions, vec![oldest_session.clone()]); + for session in sessions { + record_imported_session(root.path(), &session.path, ThreadId::new()) + .expect("record import"); + } + + let changed_at = SystemTime::now() + + Duration::from_secs(/*secs*/ SESSION_IMPORT_MAX_COUNT as u64 + 1); + for (index, session) in all_sessions.iter().enumerate() { + let title = session.title.as_deref().expect("session title"); + std::fs::write( + &session.path, + jsonl(&[ + record("user", title, &project_root), + record("assistant", "updated", &project_root), + ]), + ) + .expect("update session"); + set_modified_at( + &session.path, + changed_at - Duration::from_secs(/*secs*/ index as u64), + ); + } + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!(sessions, expected); + for session in sessions { + record_imported_session(root.path(), &session.path, ThreadId::new()) + .expect("record import"); + } + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!(sessions, vec![oldest_session]); + } + #[test] fn skips_already_imported_current_session_versions() { let root = TempDir::new().expect("tempdir"); @@ -300,6 +430,15 @@ mod tests { session_path } + fn set_modified_at(path: &Path, modified_at: SystemTime) { + OpenOptions::new() + .write(true) + .open(path) + .expect("open session") + .set_times(FileTimes::new().set_modified(modified_at)) + .expect("set session modified time"); + } + fn record(role: &str, text: &str, cwd: &Path) -> JsonValue { let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); record_at(role, text, cwd, ×tamp) diff --git a/codex-rs/external-agent-sessions/src/ledger.rs b/codex-rs/external-agent-sessions/src/ledger.rs index 84679581ca..45ff97bcf0 100644 --- a/codex-rs/external-agent-sessions/src/ledger.rs +++ b/codex-rs/external-agent-sessions/src/ledger.rs @@ -4,12 +4,16 @@ use serde::Deserialize; use serde::Serialize; use sha2::Digest; use sha2::Sha256; +use std::collections::HashMap; use std::fs; +use std::fs::File; use std::io; +use std::io::Read; use std::path::Path; use std::path::PathBuf; const SESSION_IMPORT_LEDGER_FILE: &str = "external_agent_session_imports.json"; +const SESSION_HASH_BUFFER_SIZE: usize = 64 * 1024; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub(super) struct ImportedExternalAgentSessionLedger { @@ -22,6 +26,14 @@ struct ImportedExternalAgentSessionRecord { content_sha256: String, imported_thread_id: ThreadId, imported_at: i64, + #[serde(default)] + source_modified_at: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct ImportedSourceState { + pub source_modified_at: Option, + pub imported_at: i64, } pub fn has_current_session_been_imported( @@ -39,30 +51,82 @@ pub fn record_imported_session( let mut ledger = load_import_ledger(codex_home)?; let source_path = canonical_source_path(source_path)?; let content_sha256 = session_content_sha256(&source_path)?; - if ledger - .records - .iter() - .any(|record| record.source_path == source_path && record.content_sha256 == content_sha256) - { - return Ok(()); + let source_modified_at = session_modified_at(&source_path)?; + if let Some(index) = ledger.records.iter().rposition(|record| { + record.source_path == source_path && record.content_sha256 == content_sha256 + }) { + let mut record = ledger.records.remove(index); + record.imported_thread_id = imported_thread_id; + record.imported_at = now_unix_seconds(); + record.source_modified_at = source_modified_at; + ledger.records.push(record); + } else { + ledger.records.push(ImportedExternalAgentSessionRecord { + source_path, + content_sha256, + imported_thread_id, + imported_at: now_unix_seconds(), + source_modified_at, + }); } - ledger.records.push(ImportedExternalAgentSessionRecord { - source_path, - content_sha256, - imported_thread_id, - imported_at: now_unix_seconds(), - }); save_import_ledger(codex_home, &ledger) } impl ImportedExternalAgentSessionLedger { + pub(super) fn source_states(&self) -> HashMap<&Path, ImportedSourceState> { + let mut states = HashMap::new(); + for record in &self.records { + states.insert( + record.source_path.as_path(), + ImportedSourceState { + source_modified_at: record.source_modified_at, + imported_at: record.imported_at, + }, + ); + } + states + } + pub(super) fn contains_current_source(&self, source_path: &Path) -> io::Result { let source_path = canonical_source_path(source_path)?; + if !self + .records + .iter() + .any(|record| record.source_path == source_path) + { + return Ok(false); + } let content_sha256 = session_content_sha256(&source_path)?; Ok(self.records.iter().any(|record| { record.source_path == source_path && record.content_sha256 == content_sha256 })) } + + pub(super) fn refresh_current_source( + &mut self, + source_path: &Path, + source_modified_at: i64, + ) -> io::Result { + let source_path = canonical_source_path(source_path)?; + if !self + .records + .iter() + .any(|record| record.source_path == source_path) + { + return Ok(false); + } + let content_sha256 = session_content_sha256(&source_path)?; + let Some(index) = self.records.iter().rposition(|record| { + record.source_path == source_path && record.content_sha256 == content_sha256 + }) else { + return Ok(false); + }; + let mut record = self.records.remove(index); + record.imported_at = now_unix_seconds(); + record.source_modified_at = Some(source_modified_at); + self.records.push(record); + Ok(true) + } } pub(super) fn load_import_ledger( @@ -84,7 +148,7 @@ pub(super) fn load_import_ledger( }) } -fn save_import_ledger( +pub(super) fn save_import_ledger( codex_home: &Path, ledger: &ImportedExternalAgentSessionLedger, ) -> io::Result<()> { @@ -103,6 +167,24 @@ fn canonical_source_path(path: &Path) -> io::Result { } fn session_content_sha256(path: &Path) -> io::Result { - let contents = fs::read(path)?; - Ok(format!("{:x}", Sha256::digest(contents))) + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0; SESSION_HASH_BUFFER_SIZE]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + let digest = hasher.finalize(); + Ok(format!("{digest:x}")) +} + +fn session_modified_at(path: &Path) -> io::Result> { + Ok(fs::metadata(path)? + .modified()? + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_nanos()).ok())) } diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 6106431d77..b7f666c21d 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -4,6 +4,26 @@ use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CodeModeConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Exact tool namespaces to omit from the code-mode nested tool surface. + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded_tool_namespaces: Option>, +} + +impl FeatureConfig for CodeModeConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct MultiAgentV2ConfigToml { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index ce1ee6940e..3ba973b804 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -17,6 +17,7 @@ use toml::Table; mod feature_configs; mod legacy; pub use feature_configs::AppsMcpPathOverrideConfigToml; +pub use feature_configs::CodeModeConfigToml; pub use feature_configs::MultiAgentV2ConfigToml; pub use feature_configs::NetworkProxyConfigToml; pub use feature_configs::NetworkProxyDomainPermissionToml; @@ -206,8 +207,6 @@ pub enum Feature { RealtimeConversation, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, - /// Send `response.processed` over Responses API websockets after a turn response is recorded. - ResponsesWebsocketResponseProcessed, /// Enable remote compaction v2 over the normal Responses API. RemoteCompactionV2, /// Enable workspace dependency support. @@ -600,6 +599,8 @@ pub fn is_known_feature_key(key: &str) -> bool { /// Deserializable features table for TOML. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] pub struct FeaturesToml { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code_mode: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_v2: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -620,6 +621,9 @@ impl Features { impl FeaturesToml { pub fn entries(&self) -> BTreeMap { let mut entries = self.entries.clone(); + if let Some(enabled) = self.code_mode.as_ref().and_then(FeatureToml::enabled) { + entries.insert(Feature::CodeMode.key().to_string(), enabled); + } if let Some(enabled) = self.multi_agent_v2.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::MultiAgentV2.key().to_string(), enabled); } @@ -638,6 +642,7 @@ impl FeaturesToml { pub fn materialize_resolved_enabled(&mut self, features: &Features) { let Self { + code_mode, multi_agent_v2, apps_mcp_path_override, network_proxy, @@ -648,7 +653,9 @@ impl FeaturesToml { } for spec in FEATURES { let enabled = features.enabled(spec.id); - if spec.id == Feature::MultiAgentV2 { + if spec.id == Feature::CodeMode { + materialize_resolved_feature_enabled(code_mode, enabled); + } else if spec.id == Feature::MultiAgentV2 { materialize_resolved_feature_enabled(multi_agent_v2, enabled); } else if spec.id == Feature::AppsMcpPathOverride { materialize_resolved_feature_enabled(apps_mcp_path_override, enabled); @@ -1219,12 +1226,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, - FeatureSpec { - id: Feature::ResponsesWebsocketResponseProcessed, - key: "responses_websocket_response_processed", - stage: Stage::UnderDevelopment, - default_enabled: false, - }, FeatureSpec { id: Feature::RemoteCompactionV2, key: "remote_compaction_v2", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 5d7087e90b..aa5e0174ba 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -146,22 +146,6 @@ fn remote_compaction_v2_is_under_development() { ); } -#[test] -fn responses_websocket_response_processed_is_under_development() { - assert_eq!( - Feature::ResponsesWebsocketResponseProcessed.stage(), - Stage::UnderDevelopment - ); - assert_eq!( - Feature::ResponsesWebsocketResponseProcessed.default_enabled(), - false - ); - assert_eq!( - feature_for_key("responses_websocket_response_processed"), - Some(Feature::ResponsesWebsocketResponseProcessed) - ); -} - #[test] fn terminal_resize_reflow_is_experimental_and_enabled_by_default() { assert_eq!( diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 36099fe2e8..58bdc9dfc4 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -194,7 +194,7 @@ impl MemoryStartupContext { prompt, &context.model_info, &context.session_telemetry, - context.reasoning_effort, + context.reasoning_effort.clone(), context.reasoning_summary, context.service_tier.clone(), context.turn_metadata_header.as_deref(), diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 58137a3f50..81a5c6e5ed 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -99,6 +99,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: true, // this is the fallback model metadata supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 1da6497eb0..0003cb04a5 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -458,7 +458,7 @@ impl SessionTelemetry { auth.env_provider_key_name = self.metadata.auth_env.provider_env_key_name.as_deref(), auth.env_provider_key_present = self.metadata.auth_env.provider_env_key_present, auth.env_refresh_token_url_override_present = self.metadata.auth_env.refresh_token_url_override_present, - reasoning_effort = reasoning_effort.map(|e| e.to_string()), + reasoning_effort = reasoning_effort.as_ref().map(ToString::to_string), reasoning_summary = %reasoning_summary, context_window = context_window, auto_compact_token_limit = auto_compact_token_limit, @@ -998,6 +998,37 @@ impl SessionTelemetry { ); } + pub fn sandbox_outcome( + &self, + tool_name: &str, + call_id: &str, + outcome: &str, + initial_duration: Duration, + escalated_duration: Option, + ) { + let initial_duration_ms = initial_duration.as_millis().min(i64::MAX as u128) as i64; + let escalated_duration_ms = + escalated_duration.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64); + log_event!( + self, + event.name = "codex.sandbox_outcome", + tool_name = %tool_name, + call_id = %call_id, + outcome = %outcome, + initial_duration_ms = initial_duration_ms, + escalated_duration_ms = escalated_duration_ms, + ); + trace_event!( + self, + event.name = "codex.sandbox_outcome", + tool_name = %tool_name, + call_id = %call_id, + outcome = %outcome, + initial_duration_ms = initial_duration_ms, + escalated_duration_ms = escalated_duration_ms, + ); + } + #[allow(clippy::too_many_arguments)] pub async fn log_tool_result_with_tags( &self, diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 23a44c48e6..89b10e383e 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -59,4 +59,5 @@ tempfile = { workspace = true } [package.metadata.cargo-shear] # Required because: # `icu_provider`: contains a required `sync` feature for `icu_decimal` -ignored = ["icu_provider"] +# `strum`: is referenced by generated `EnumIter` derive implementations +ignored = ["icu_provider", "strum"] diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 9e5c47a5c1..6654f3166b 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -635,7 +635,7 @@ impl CollaborationMode { } pub fn reasoning_effort(&self) -> Option { - self.settings_ref().reasoning_effort + self.settings_ref().reasoning_effort.clone() } /// Updates the collaboration mode with new model and/or effort values. @@ -654,7 +654,7 @@ impl CollaborationMode { let settings = self.settings_ref(); let updated_settings = Settings { model: model.unwrap_or_else(|| settings.model.clone()), - reasoning_effort: effort.unwrap_or(settings.reasoning_effort), + reasoning_effort: effort.unwrap_or_else(|| settings.reasoning_effort.clone()), developer_instructions: developer_instructions .unwrap_or_else(|| settings.developer_instructions.clone()), }; @@ -676,7 +676,10 @@ impl CollaborationMode { mode: mask.mode.unwrap_or(self.mode), settings: Settings { model: mask.model.clone().unwrap_or_else(|| settings.model.clone()), - reasoning_effort: mask.reasoning_effort.unwrap_or(settings.reasoning_effort), + reasoning_effort: mask + .reasoning_effort + .clone() + .unwrap_or_else(|| settings.reasoning_effort.clone()), developer_instructions: mask .developer_instructions .clone() diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 4b678a9209..1553ef3435 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -3,15 +3,22 @@ //! These types are serialized across core, TUI, app-server, and SDK boundaries, so field defaults //! are used to preserve compatibility when older payloads omit newly introduced attributes. -use std::collections::HashMap; +use std::fmt; use std::str::FromStr; use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::InstanceType; +use schemars::schema::Metadata; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use schemars::schema::StringValidation; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; +use serde::Serializer; use serde::de::DeserializeOwned; -use strum::IntoEnumIterator; +use serde::de::Error; use strum_macros::Display; use strum_macros::EnumIter; use tracing::warn; @@ -28,23 +35,8 @@ const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; pub const SPEED_TIER_FAST: &str = "fast"; /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning -#[derive( - Debug, - Serialize, - Deserialize, - Default, - Clone, - Copy, - PartialEq, - Eq, - Display, - JsonSchema, - TS, - EnumIter, - Hash, -)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] +#[derive(Debug, Default, Clone, PartialEq, Eq, TS, Hash)] +#[ts(type = "string")] pub enum ReasoningEffort { None, Minimal, @@ -53,14 +45,87 @@ pub enum ReasoningEffort { Medium, High, XHigh, + /// A model-defined effort value that this client does not know yet. + Custom(String), +} + +impl ReasoningEffort { + /// Returns the exact value used on the wire. + pub fn as_str(&self) -> &str { + match self { + Self::None => "none", + Self::Minimal => "minimal", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + Self::XHigh => "xhigh", + Self::Custom(effort) => effort, + } + } +} + +impl fmt::Display for ReasoningEffort { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl JsonSchema for ReasoningEffort { + fn schema_name() -> String { + "ReasoningEffort".to_string() + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + Schema::Object(SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some( + "A non-empty reasoning effort value advertised by the model.".to_string(), + ), + ..Default::default() + })), + string: Some(Box::new(StringValidation { + min_length: Some(1), + ..Default::default() + })), + ..Default::default() + }) + } +} + +impl Serialize for ReasoningEffort { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ReasoningEffort { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let effort = String::deserialize(deserializer)?; + effort.parse().map_err(D::Error::custom) + } } impl FromStr for ReasoningEffort { type Err = String; fn from_str(s: &str) -> Result { - serde_json::from_value(serde_json::Value::String(s.to_string())) - .map_err(|_| format!("invalid reasoning_effort: {s}")) + match s { + "none" => Ok(Self::None), + "minimal" => Ok(Self::Minimal), + "low" => Ok(Self::Low), + "medium" => Ok(Self::Medium), + "high" => Ok(Self::High), + "xhigh" => Ok(Self::XHigh), + "" => Err("reasoning_effort must not be empty".to_string()), + effort => Ok(Self::Custom(effort.to_string())), + } } } @@ -108,7 +173,6 @@ pub struct ReasoningEffortPreset { #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct ModelUpgrade { pub id: String, - pub reasoning_effort_mapping: Option>, pub migration_config_key: String, pub model_link: Option, pub upgrade_copy: Option, @@ -340,6 +404,8 @@ pub struct ModelInfo { pub used_fallback_model_metadata: bool, #[serde(default)] pub supports_search_tool: bool, + #[serde(default)] + pub use_responses_lite: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_review_model_override: Option, #[serde( @@ -501,9 +567,6 @@ impl From for ModelPreset { is_default: false, // default is the highest priority available model upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade { id: upgrade.model.clone(), - reasoning_effort_mapping: reasoning_effort_mapping_from_presets( - &info.supported_reasoning_levels, - ), migration_config_key: info.slug.clone(), // todo(aibrahim): add the model link here. model_link: None, @@ -571,47 +634,12 @@ impl ModelPreset { } } -fn reasoning_effort_mapping_from_presets( - presets: &[ReasoningEffortPreset], -) -> Option> { - if presets.is_empty() { - return None; - } - - // Map every canonical effort to the closest supported effort for the new model. - let supported: Vec = presets.iter().map(|p| p.effort).collect(); - let mut map = HashMap::new(); - for effort in ReasoningEffort::iter() { - let nearest = nearest_effort(effort, &supported); - map.insert(effort, nearest); - } - Some(map) -} - -fn effort_rank(effort: ReasoningEffort) -> i32 { - match effort { - ReasoningEffort::None => 0, - ReasoningEffort::Minimal => 1, - ReasoningEffort::Low => 2, - ReasoningEffort::Medium => 3, - ReasoningEffort::High => 4, - ReasoningEffort::XHigh => 5, - } -} - -fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort { - let target_rank = effort_rank(target); - supported - .iter() - .copied() - .min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs()) - .unwrap_or(target) -} - #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; + use serde_json::from_str; + use serde_json::to_string; fn test_model(spec: Option) -> ModelInfo { ModelInfo { @@ -648,6 +676,7 @@ mod tests { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -663,16 +692,58 @@ mod tests { } #[test] - fn reasoning_effort_from_str_accepts_known_values() { - assert_eq!("high".parse(), Ok(ReasoningEffort::High)); - assert_eq!("minimal".parse(), Ok(ReasoningEffort::Minimal)); + fn reasoning_effort_accepts_known_and_custom_values() { + let custom = ReasoningEffort::Custom("max".to_string()); + let deserialized = from_str::(r#""max""#) + .expect("custom reasoning effort should deserialize"); + let serialized = to_string(&custom).expect("custom reasoning effort should serialize"); + + assert_eq!( + ( + "high".parse(), + "max".parse(), + deserialized, + serialized, + custom.to_string(), + ), + ( + Ok(ReasoningEffort::High), + Ok(custom.clone()), + custom, + r#""max""#.to_string(), + "max".to_string(), + ) + ); } #[test] - fn reasoning_effort_from_str_rejects_unknown_values() { + fn reasoning_effort_rejects_empty_values() { assert_eq!( - "unsupported".parse::(), - Err("invalid reasoning_effort: unsupported".to_string()) + "".parse::(), + Err("reasoning_effort must not be empty".to_string()) + ); + } + + #[test] + fn reasoning_effort_json_schema_is_an_open_string() { + let mut effort_generator = SchemaGenerator::default(); + + assert_eq!( + ReasoningEffort::json_schema(&mut effort_generator), + Schema::Object(SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some( + "A non-empty reasoning effort value advertised by the model.".to_string(), + ), + ..Default::default() + })), + string: Some(Box::new(StringValidation { + min_length: Some(1), + ..Default::default() + })), + ..Default::default() + }) ); } @@ -868,6 +939,7 @@ mod tests { assert!(!model.supports_image_detail_original); assert_eq!(model.web_search_tool_type, WebSearchToolType::Text); assert!(!model.supports_search_tool); + assert!(!model.use_responses_lite); assert_eq!(model.auto_review_model_override, None); assert_eq!(model.tool_mode, None); } diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 1bba63fbf3..63cb06fea1 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -75,7 +75,7 @@ fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem) metadata.cwd = turn_ctx.cwd.clone(); } metadata.model = Some(turn_ctx.model.clone()); - metadata.reasoning_effort = turn_ctx.effort; + metadata.reasoning_effort = turn_ctx.effort.clone(); metadata.sandbox_policy = serde_json::to_string(&turn_ctx.permission_profile()).unwrap_or_default(); metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy); diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index 8f1ac4f556..cfcd49d5f9 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -584,13 +584,13 @@ mod tests { } #[test] - fn thread_row_ignores_unknown_reasoning_effort_values() { + fn thread_row_preserves_model_defined_reasoning_effort_values() { let metadata = ThreadMetadata::try_from(thread_row(Some("future"))) .expect("thread metadata should parse"); assert_eq!( metadata, - expected_thread_metadata(/*reasoning_effort*/ None) + expected_thread_metadata(Some(ReasoningEffort::Custom("future".to_string()))) ); } } diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 9305a6f668..47e0fc3911 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -266,6 +266,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R web_search_mode: Constrained::allow_any(WebSearchMode::Disabled), web_search_config: None, experimental_request_user_input_enabled: true, + code_mode: Default::default(), use_experimental_unified_exec_tool: false, background_terminal_max_timeout: 300_000, ghost_snapshot: GhostSnapshotConfig::default(), diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index b02fc15855..3a44c26b32 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -369,7 +369,7 @@ fn stored_thread_from_state( .and_then(|metadata| metadata.model_provider.clone()) .unwrap_or_else(|| "test".to_string()), model: metadata.and_then(|metadata| metadata.model.clone()), - reasoning_effort: metadata.and_then(|metadata| metadata.reasoning_effort), + reasoning_effort: metadata.and_then(|metadata| metadata.reasoning_effort.clone()), created_at: metadata .and_then(|metadata| metadata.created_at) .unwrap_or_else(Utc::now), diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index d5673b3665..4fb9f398c1 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -232,7 +232,7 @@ impl ThreadMetadataSync { update.cwd = Some(turn_ctx.cwd.clone()); } update.model = Some(turn_ctx.model.clone()); - update.reasoning_effort = turn_ctx.effort; + update.reasoning_effort = turn_ctx.effort.clone(); update.approval_mode = Some(turn_ctx.approval_policy); update.permission_profile = Some(turn_ctx.permission_profile()); } diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 1b4d789394..0e3917d697 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -44,6 +44,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { input_modalities: codex_protocol::openai_models::default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index ec72c5974a..946223ec3e 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -712,21 +712,17 @@ impl App { .add_info_message("Reset local memories.".to_string(), /*hint*/ None); } - pub(super) fn reasoning_label(reasoning_effort: Option) -> &'static str { + pub(super) fn reasoning_label(reasoning_effort: Option<&ReasoningEffortConfig>) -> String { match reasoning_effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", - None | Some(ReasoningEffortConfig::None) => "default", + None | Some(ReasoningEffortConfig::None) => "default".to_string(), + Some(reasoning_effort) => reasoning_effort.as_str().to_string(), } } pub(super) fn reasoning_label_for( model: &str, - reasoning_effort: Option, - ) -> Option<&'static str> { + reasoning_effort: Option<&ReasoningEffortConfig>, + ) -> Option { (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) } @@ -737,7 +733,7 @@ impl App { pub(super) fn on_update_reasoning_effort(&mut self, effort: Option) { // TODO(aibrahim): Remove this and don't use config as a state object. // Instead, explicitly pass the stored collaboration mode's effort into new sessions. - self.config.model_reasoning_effort = effort; + self.config.model_reasoning_effort = effort.clone(); self.chat_widget.set_reasoning_effort(effort); } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b4dfb93fb7..58f090e4c7 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -750,7 +750,7 @@ impl App { self.chat_widget.on_connectors_loaded(result, is_final); } AppEvent::UpdateReasoningEffort(effort) => { - self.on_update_reasoning_effort(effort); + self.on_update_reasoning_effort(effort.clone()); self.sync_active_thread_reasoning_setting(app_server, effort) .await; } @@ -1300,19 +1300,23 @@ impl App { AppEvent::PersistModelSelection { model, effort } => { match crate::config_update::write_config_batch( app_server.request_handle(), - crate::config_update::build_model_selection_edits(model.as_str(), effort), + crate::config_update::build_model_selection_edits( + model.as_str(), + effort.as_ref(), + ), ) .await { Ok(_) => { let effort_label = effort - .map(|selected_effort| selected_effort.to_string()) + .as_ref() + .map(std::string::ToString::to_string) .unwrap_or_else(|| "default".to_string()); tracing::info!("Selected model: {model}, Selected effort: {effort_label}"); let mut message = format!("Model changed to {model}"); - if let Some(label) = Self::reasoning_label_for(&model, effort) { + if let Some(label) = Self::reasoning_label_for(&model, effort.as_ref()) { message.push(' '); - message.push_str(label); + message.push_str(&label); } self.chat_widget.add_info_message(message, /*hint*/ None); } @@ -1607,7 +1611,7 @@ impl App { self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); } AppEvent::UpdatePlanModeReasoningEffort(effort) => { - self.config.plan_mode_reasoning_effort = effort; + self.config.plan_mode_reasoning_effort = effort.clone(); self.chat_widget.set_plan_mode_reasoning_effort(effort); self.sync_active_thread_plan_mode_reasoning_setting(app_server) .await; diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 802cda3f80..a027dcd505 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -152,9 +152,11 @@ pub(super) fn apply_accepted_model_migration( }); config.model = Some(target_model.clone()); - config.model_reasoning_effort = Some(target_default_effort); + config.model_reasoning_effort = Some(target_default_effort.clone()); app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); - app_event_tx.send(AppEvent::UpdateReasoningEffort(Some(target_default_effort))); + app_event_tx.send(AppEvent::UpdateReasoningEffort(Some( + target_default_effort.clone(), + ))); app_event_tx.send(AppEvent::PersistModelSelection { model: target_model, effort: Some(target_default_effort), @@ -240,7 +242,6 @@ pub(super) async fn handle_model_migration_prompt_if_needed( if let Some(ModelUpgrade { id: target_model, - reasoning_effort_mapping: _, migration_config_key, model_link, upgrade_copy, @@ -290,7 +291,7 @@ pub(super) async fn handle_model_migration_prompt_if_needed( app_event_tx, model.to_string(), target_model.clone(), - target_preset.default_reasoning_effort, + target_preset.default_reasoning_effort.clone(), ); } ModelMigrationOutcome::Rejected => { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index eb5e10c50f..b18726004e 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5241,7 +5241,7 @@ async fn override_turn_context_sends_thread_settings_update() { .expect("thread/start should succeed"); let thread_id = started.session.thread_id; let initial_model = started.session.model.clone(); - let initial_effort = started.session.reasoning_effort; + let initial_effort = started.session.reasoning_effort.clone(); app.enqueue_primary_thread_session(started.session, started.turns) .await .expect("primary thread should be registered"); @@ -5466,7 +5466,7 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode model: "gpt-plan".to_string(), model_provider: "openai".to_string(), service_tier: None, - effort: collaboration_mode.settings.reasoning_effort, + effort: collaboration_mode.settings.reasoning_effort.clone(), summary: None, collaboration_mode: collaboration_mode.clone(), personality: Some(Personality::Pragmatic), diff --git a/codex-rs/tui/src/app/tests/model_catalog.rs b/codex-rs/tui/src/app/tests/model_catalog.rs index 7d2eb9c125..961c65159a 100644 --- a/codex-rs/tui/src/app/tests/model_catalog.rs +++ b/codex-rs/tui/src/app/tests/model_catalog.rs @@ -297,7 +297,6 @@ async fn model_migration_prompt_skips_when_target_missing_or_hidden() { .expect("preset present"); current.upgrade = Some(ModelUpgrade { id: "missing-target".to_string(), - reasoning_effort_mapping: None, migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(), model_link: None, upgrade_copy: None, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 1bd28d5f65..c53cfc05e3 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -606,7 +606,7 @@ impl App { permissions_override, config.permissions.user_visible_workspace_roots(), model.to_string(), - *effort, + effort.clone(), *summary, service_tier.clone(), collaboration_mode.clone(), diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs index 27c435af3f..84dcebe1a8 100644 --- a/codex-rs/tui/src/app/thread_settings.rs +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -124,7 +124,7 @@ impl App { .as_ref() .map(|profile| profile.id.clone()), model: model.clone(), - effort: effort.unwrap_or_default(), + effort: effort.clone().unwrap_or_default(), summary: *summary, service_tier: service_tier.clone(), collaboration_mode: collaboration_mode.clone(), @@ -172,7 +172,7 @@ impl App { fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: &ThreadSettings) { if settings.collaboration_mode.mode == ModeKind::Default { session.model = settings.model.clone(); - session.reasoning_effort = settings.effort; + session.reasoning_effort = settings.effort.clone(); } session.model_provider_id = settings.model_provider.clone(); session.service_tier = settings.service_tier.clone(); @@ -190,7 +190,7 @@ fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: .settings .model .clone_from(&settings.model); - collaboration_mode.settings.reasoning_effort = settings.effort; + collaboration_mode.settings.reasoning_effort = settings.effort.clone(); session.collaboration_mode = Some(Box::new(collaboration_mode)); } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 2290640ab4..ccd6304d76 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1204,7 +1204,6 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { let upgrade_info = model.upgrade_info.clone(); ModelUpgrade { id: upgrade_id, - reasoning_effort_mapping: None, migration_config_key: model.model.clone(), model_link: upgrade_info .as_ref() @@ -1273,7 +1272,8 @@ fn config_request_overrides_from_config( "model_reasoning_effort", config .model_reasoning_effort - .map(|effort| effort.to_string()), + .as_ref() + .map(std::string::ToString::to_string), ); insert( "model_reasoning_summary", @@ -1586,7 +1586,7 @@ async fn thread_session_state_from_thread_start_response( response.cwd.clone(), response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), config, ) .await @@ -1627,7 +1627,7 @@ async fn thread_session_state_from_thread_resume_response( response.cwd.clone(), response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), config, ) .await @@ -1659,7 +1659,7 @@ async fn thread_session_state_from_thread_fork_response( response.cwd.clone(), response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), config, ) .await diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 3930224e04..ddb118baec 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -166,7 +166,7 @@ impl ChatWidget { mut collaboration_mode: CollaborationModeMask, ) { if collaboration_mode.mode == Some(ModeKind::Plan) - && let Some(effort) = self.config.plan_mode_reasoning_effort + && let Some(effort) = self.config.plan_mode_reasoning_effort.clone() { collaboration_mode.reasoning_effort = Some(Some(effort)); } diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index 2c0d5aba95..90fd6c2cc3 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -100,11 +100,11 @@ impl ChatWidget { let model = preset.model.clone(); let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( model.as_str(), - Some(preset.default_reasoning_effort), + Some(preset.default_reasoning_effort.clone()), ); let actions = Self::model_selection_actions( model.clone(), - Some(preset.default_reasoning_effort), + Some(preset.default_reasoning_effort.clone()), should_prompt_plan_mode_scope, ); SelectionItem { @@ -222,16 +222,16 @@ impl ChatWidget { if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { model: model_for_action.clone(), - effort: effort_for_action, + effort: effort_for_action.clone(), }); return; } tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); + tx.send(AppEvent::UpdateReasoningEffort(effort_for_action.clone())); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), - effort: effort_for_action, + effort: effort_for_action.clone(), }); })] } @@ -261,30 +261,34 @@ impl ChatWidget { model: String, effort: Option, ) { - let reasoning_phrase = match effort { + let reasoning_phrase = match effort.as_ref() { Some(ReasoningEffortConfig::None) => "no reasoning".to_string(), Some(selected_effort) => { format!( "{} reasoning", - Self::reasoning_effort_label(selected_effort).to_lowercase() + Self::reasoning_effort_sentence_label(selected_effort) ) } None => "the selected reasoning".to_string(), }; let plan_only_description = format!("Always use {reasoning_phrase} in Plan mode."); let plan_reasoning_source = if let Some(plan_override) = - self.config.plan_mode_reasoning_effort + self.config.plan_mode_reasoning_effort.as_ref() { format!( "user-chosen Plan override ({})", - Self::reasoning_effort_label(plan_override).to_lowercase() + Self::reasoning_effort_sentence_label(plan_override) ) } else if let Some(plan_mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { - match plan_mask.reasoning_effort.flatten() { + match plan_mask + .reasoning_effort + .as_ref() + .and_then(|effort| effort.as_ref()) + { Some(plan_effort) => format!( "built-in Plan default ({})", - Self::reasoning_effort_label(plan_effort).to_lowercase() + Self::reasoning_effort_sentence_label(plan_effort) ), None => "built-in Plan default (no reasoning)".to_string(), } @@ -298,20 +302,21 @@ impl ChatWidget { let plan_only_actions: Vec = vec![Box::new({ let model = model.clone(); + let effort = effort.clone(); move |tx| { tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone())); + tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone())); } })]; let all_modes_actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort)); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + tx.send(AppEvent::UpdateReasoningEffort(effort.clone())); + tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone())); + tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone())); tx.send(AppEvent::PersistModelSelection { model: model.clone(), - effort, + effort: effort.clone(), }); })]; @@ -344,7 +349,7 @@ impl ChatWidget { /// Open a popup to choose the reasoning effort (stage 2) for the given model. pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { - let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + let default_effort = preset.default_reasoning_effort; let supported = preset.supported_reasoning_efforts; let in_plan_mode = self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan; @@ -362,7 +367,7 @@ impl ChatWidget { } else { None }; - let warning_text = warn_effort.map(|effort| { + let warning_text = warn_effort.as_ref().map(|effort| { let effort_label = Self::reasoning_effort_label(effort); format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") }); @@ -370,30 +375,20 @@ impl ChatWidget { || preset.model.starts_with("gpt-5.1-codex-max") || preset.model.starts_with("gpt-5.2"); - struct EffortChoice { - stored: Option, - display: ReasoningEffortConfig, - } - let mut choices: Vec = Vec::new(); - for effort in ReasoningEffortConfig::iter() { - if supported.iter().any(|option| option.effort == effort) { - choices.push(EffortChoice { - stored: Some(effort), - display: effort, - }); - } - } + let mut choices: Vec = supported + .iter() + .map(|option| option.effort.clone()) + .collect(); if choices.is_empty() { - choices.push(EffortChoice { - stored: Some(default_effort), - display: default_effort, - }); + choices.push(default_effort.clone()); } if choices.len() == 1 { - let selected_effort = choices.first().and_then(|c| c.stored); + let selected_effort = choices.first().cloned(); let selected_model = preset.model; - if self.should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort) { + if self + .should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort.clone()) + { self.app_event_tx .send(AppEvent::OpenPlanReasoningScopePrompt { model: selected_model, @@ -405,12 +400,10 @@ impl ChatWidget { return; } - let default_choice: Option = choices - .iter() - .any(|choice| choice.stored == Some(default_effort)) - .then_some(Some(default_effort)) - .flatten() - .or_else(|| choices.iter().find_map(|choice| choice.stored)) + let default_choice = choices + .contains(&default_effort) + .then(|| default_effort.clone()) + .or_else(|| choices.first().cloned()) .or(Some(default_effort)); let model_slug = preset.model.to_string(); @@ -419,40 +412,33 @@ impl ChatWidget { if in_plan_mode { self.config .plan_mode_reasoning_effort - .or(self.effective_reasoning_effort()) + .clone() + .or_else(|| self.effective_reasoning_effort()) } else { self.effective_reasoning_effort() } } else { - default_choice + default_choice.clone() }; - let selection_choice = highlight_choice.or(default_choice); + let selection_choice = highlight_choice.clone().or_else(|| default_choice.clone()); let initial_selected_idx = choices .iter() - .position(|choice| choice.stored == selection_choice) - .or_else(|| { - selection_choice - .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) - }); + .position(|choice| Some(choice) == selection_choice.as_ref()); let mut items: Vec = Vec::new(); for choice in choices.iter() { - let effort = choice.display; - let mut effort_label = Self::reasoning_effort_label(effort).to_string(); - if choice.stored == default_choice { + let effort = choice.clone(); + let mut effort_label = Self::reasoning_effort_label(&effort); + if Some(choice) == default_choice.as_ref() { effort_label.push_str(" (default)"); } - let description = choice - .stored - .and_then(|effort| { - supported - .iter() - .find(|option| option.effort == effort) - .map(|option| option.description.to_string()) - }) + let description = supported + .iter() + .find(|option| option.effort == effort) + .map(|option| option.description.to_string()) .filter(|text| !text.is_empty()); - let show_warning = warn_for_model && warn_effort == Some(effort); + let show_warning = warn_for_model && warn_effort.as_ref() == Some(&effort); let selected_description = if show_warning { warning_text.as_ref().map(|warning_message| { description.as_ref().map_or_else( @@ -465,21 +451,23 @@ impl ChatWidget { }; let model_for_action = model_slug.clone(); - let choice_effort = choice.stored; - let should_prompt_plan_mode_scope = - self.should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), choice_effort); + let choice_effort = Some(effort); + let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( + model_slug.as_str(), + choice_effort.clone(), + ); let actions: Vec = vec![Box::new(move |tx| { if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { model: model_for_action.clone(), - effort: choice_effort, + effort: choice_effort.clone(), }); } else { tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(choice_effort)); + tx.send(AppEvent::UpdateReasoningEffort(choice_effort.clone())); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), - effort: choice_effort, + effort: choice_effort.clone(), }); } })]; @@ -488,7 +476,7 @@ impl ChatWidget { name: effort_label, description, selected_description, - is_current: is_current_model && choice.stored == highlight_choice, + is_current: is_current_model && Some(choice) == highlight_choice.as_ref(), actions, dismiss_on_select: true, ..Default::default() @@ -509,14 +497,22 @@ impl ChatWidget { }); } - pub(super) fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { + pub(super) fn reasoning_effort_label(effort: &ReasoningEffortConfig) -> String { match effort { - ReasoningEffortConfig::None => "None", - ReasoningEffortConfig::Minimal => "Minimal", - ReasoningEffortConfig::Low => "Low", - ReasoningEffortConfig::Medium => "Medium", - ReasoningEffortConfig::High => "High", - ReasoningEffortConfig::XHigh => "Extra high", + ReasoningEffortConfig::None => "None".to_string(), + ReasoningEffortConfig::Minimal => "Minimal".to_string(), + ReasoningEffortConfig::Low => "Low".to_string(), + ReasoningEffortConfig::Medium => "Medium".to_string(), + ReasoningEffortConfig::High => "High".to_string(), + ReasoningEffortConfig::XHigh => "Extra high".to_string(), + ReasoningEffortConfig::Custom(value) => value.clone(), + } + } + + pub(super) fn reasoning_effort_sentence_label(effort: &ReasoningEffortConfig) -> String { + match effort { + ReasoningEffortConfig::Custom(value) => value.clone(), + effort => Self::reasoning_effort_label(effort).to_lowercase(), } } @@ -531,7 +527,7 @@ impl ChatWidget { } fn apply_model_and_effort(&self, model: String, effort: Option) { - self.apply_model_and_effort_without_persist(model.clone(), effort); + self.apply_model_and_effort_without_persist(model.clone(), effort.clone()); self.app_event_tx .send(AppEvent::PersistModelSelection { model, effort }); } diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index f831b5a007..efb0b63628 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -343,14 +343,16 @@ impl ChatWidget { /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, Some(switch_model_for_events.clone()), - Some(Some(default_effort)), + Some(Some(default_effort.clone())), /*summary*/ None, /*service_tier*/ None, /*collaboration_mode*/ None, /*personality*/ None, ))); tx.send(AppEvent::UpdateModel(switch_model_for_events.clone())); - tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); + tx.send(AppEvent::UpdateReasoningEffort(Some( + default_effort.clone(), + ))); })]; let keep_actions: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs index 612dbe7ddd..404bf87029 100644 --- a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs +++ b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs @@ -8,15 +8,14 @@ //! The shortcut state machine is deliberately narrow: it only handles key //! presses when no modal or popup owns input, it anchors unset reasoning to the //! current model preset's default, and it walks only efforts advertised by the -//! active model. Unsupported current efforts are not normalized eagerly; the -//! next shortcut moves to the nearest supported effort in the requested -//! direction. +//! active model. Unsupported efforts anchor to the model default, or the first +//! advertised effort when the default is absent, before stepping through the +//! advertised order. use codex_protocol::config_types::ModeKind; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use crossterm::event::KeyEvent; -use strum::IntoEnumIterator; use super::ChatWidget; use crate::app_event::AppEvent; @@ -30,8 +29,8 @@ pub(super) enum ReasoningShortcutDirection { } impl ReasoningShortcutDirection { - fn bound_message(self, effort: ReasoningEffortConfig) -> String { - let label = ChatWidget::reasoning_effort_label(effort).to_lowercase(); + fn bound_message(self, effort: &ReasoningEffortConfig) -> String { + let label = ChatWidget::reasoning_effort_sentence_label(effort); match self { Self::Lower => format!("Reasoning is already at the lowest level ({label})."), Self::Raise => format!("Reasoning is already at the highest level ({label})."), @@ -90,12 +89,23 @@ impl ChatWidget { }; let choices = reasoning_choices(&preset); - let current_effort = self + let configured_effort = self .effective_reasoning_effort() - .unwrap_or(preset.default_reasoning_effort); - let Some(next_effort) = next_reasoning_effort(&choices, Some(current_effort), direction) + .unwrap_or_else(|| preset.default_reasoning_effort.clone()); + let current_effort = if choices.contains(&configured_effort) { + configured_effort + } else if choices.contains(&preset.default_reasoning_effort) { + preset.default_reasoning_effort + } else { + choices + .first() + .cloned() + .unwrap_or(preset.default_reasoning_effort) + }; + let Some(next_effort) = + next_reasoning_effort(&choices, Some(current_effort.clone()), direction) else { - self.add_info_message(direction.bound_message(current_effort), /*hint*/ None); + self.add_info_message(direction.bound_message(¤t_effort), /*hint*/ None); return true; }; @@ -120,18 +130,13 @@ impl ChatWidget { } fn reasoning_choices(preset: &ModelPreset) -> Vec { - let mut choices = Vec::new(); - for effort in ReasoningEffortConfig::iter() { - if preset - .supported_reasoning_efforts - .iter() - .any(|option| option.effort == effort) - { - choices.push(effort); - } - } + let mut choices: Vec = preset + .supported_reasoning_efforts + .iter() + .map(|option| option.effort.clone()) + .collect(); if choices.is_empty() { - choices.push(preset.default_reasoning_effort); + choices.push(preset.default_reasoning_effort.clone()); } choices } @@ -142,33 +147,17 @@ fn next_reasoning_effort( direction: ReasoningShortcutDirection, ) -> Option { let current_effort = current_effort?; - if choices.is_empty() { - return None; + if let Some(current_index) = choices.iter().position(|choice| choice == ¤t_effort) { + return match direction { + ReasoningShortcutDirection::Lower => current_index + .checked_sub(1) + .and_then(|index| choices.get(index)) + .cloned(), + ReasoningShortcutDirection::Raise => choices.get(current_index + 1).cloned(), + }; } - let current_rank = effort_rank(current_effort); - match direction { - ReasoningShortcutDirection::Lower => choices - .iter() - .rev() - .copied() - .find(|choice| effort_rank(*choice) < current_rank), - ReasoningShortcutDirection::Raise => choices - .iter() - .copied() - .find(|choice| effort_rank(*choice) > current_rank), - } -} - -fn effort_rank(effort: ReasoningEffortConfig) -> i32 { - match effort { - ReasoningEffortConfig::None => 0, - ReasoningEffortConfig::Minimal => 1, - ReasoningEffortConfig::Low => 2, - ReasoningEffortConfig::Medium => 3, - ReasoningEffortConfig::High => 4, - ReasoningEffortConfig::XHigh => 5, - } + None } #[cfg(test)] @@ -214,24 +203,52 @@ mod tests { } #[test] - fn next_reasoning_effort_skips_to_supported_level_from_unsupported_current() { + fn next_reasoning_effort_does_not_infer_position_for_unsupported_current() { let choices = vec![ReasoningEffortConfig::Low, ReasoningEffortConfig::High]; assert_eq!( - next_reasoning_effort( - &choices, - Some(ReasoningEffortConfig::Medium), - ReasoningShortcutDirection::Raise, + ( + next_reasoning_effort( + &choices, + Some(ReasoningEffortConfig::Medium), + ReasoningShortcutDirection::Raise, + ), + next_reasoning_effort( + &choices, + Some(ReasoningEffortConfig::Medium), + ReasoningShortcutDirection::Lower, + ), ), - Some(ReasoningEffortConfig::High) + (None, None) ); + } + + #[test] + fn next_reasoning_effort_uses_advertised_order_for_custom_levels() { + let custom_effort = ReasoningEffortConfig::Custom("max".to_string()); + let choices = vec![ + ReasoningEffortConfig::High, + ReasoningEffortConfig::Low, + custom_effort.clone(), + ]; + assert_eq!( - next_reasoning_effort( - &choices, - Some(ReasoningEffortConfig::Medium), - ReasoningShortcutDirection::Lower, + ( + next_reasoning_effort( + &choices, + Some(ReasoningEffortConfig::High), + ReasoningShortcutDirection::Raise, + ), + next_reasoning_effort( + &choices, + Some(custom_effort), + ReasoningShortcutDirection::Lower, + ), ), - Some(ReasoningEffortConfig::Low) + ( + Some(ReasoningEffortConfig::Low), + Some(ReasoningEffortConfig::Low), + ) ); } diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 6eb5910afe..004f64c726 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -79,7 +79,7 @@ impl ChatWidget { let default_model = session.model.clone(); self.current_collaboration_mode = self.current_collaboration_mode.with_updates( Some(default_model.clone()), - Some(session.reasoning_effort), + Some(session.reasoning_effort.clone()), /*developer_instructions*/ None, ); match session.collaboration_mode.as_deref() { @@ -93,7 +93,7 @@ impl ChatWidget { Some(&default_model), ); if let Some(mask) = self.active_collaboration_mask.as_mut() { - mask.reasoning_effort = Some(session.reasoning_effort); + mask.reasoning_effort = Some(session.reasoning_effort.clone()); } self.update_collaboration_mode_indicator(); self.refresh_plan_mode_nudge(); diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 45dbc362e9..c05788acaf 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -159,7 +159,7 @@ impl ChatWidget { /// so the footer reflects it without waiting for the next mode switch. /// Passing `None` resets to the Plan-mode preset default. pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option) { - self.config.plan_mode_reasoning_effort = effort; + self.config.plan_mode_reasoning_effort = effort.clone(); if self.collaboration_modes_enabled() && let Some(mask) = self.active_collaboration_mask.as_mut() && mask.mode == Some(ModeKind::Plan) @@ -182,7 +182,7 @@ impl ChatWidget { pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.current_collaboration_mode = self.current_collaboration_mode.with_updates( /*model*/ None, - Some(effort), + Some(effort.clone()), /*developer_instructions*/ None, ); if self.collaboration_modes_enabled() @@ -471,7 +471,7 @@ impl ChatWidget { let current_effort = self.current_collaboration_mode.reasoning_effort(); self.active_collaboration_mask .as_ref() - .and_then(|mask| mask.reasoning_effort) + .and_then(|mask| mask.reasoning_effort.clone()) .unwrap_or(current_effort) } @@ -590,7 +590,7 @@ impl ChatWidget { name: mode_kind.display_name().to_string(), mode: Some(mode_kind), model: Some(settings.model.clone()), - reasoning_effort: Some(settings.reasoning_effort), + reasoning_effort: Some(settings.reasoning_effort.clone()), developer_instructions: Some(settings.developer_instructions), }); self.update_collaboration_mode_indicator(); @@ -712,7 +712,7 @@ impl ChatWidget { let previous_model = self.current_model().to_string(); let previous_effort = self.effective_reasoning_effort(); if mask.mode == Some(ModeKind::Plan) - && let Some(effort) = self.config.plan_mode_reasoning_effort + && let Some(effort) = self.config.plan_mode_reasoning_effort.clone() { mask.reasoning_effort = Some(Some(effort)); } @@ -732,13 +732,9 @@ impl ChatWidget { { let mut message = format!("Model changed to {next_model}"); if !next_model.starts_with("codex-auto-") { - let reasoning_label = match next_effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", + let reasoning_label = match next_effort.as_ref() { None | Some(ReasoningEffortConfig::None) => "default", + Some(effort) => effort.as_str(), }; message.push(' '); message.push_str(reasoning_label); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index baef861f4c..a436136570 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -6,7 +6,8 @@ expression: popup 1. Low Fast responses with lighter reasoning 2. Medium (default) Balances speed and reasoning depth for everyday tasks -› 3. High (current) Greater reasoning depth for complex problems - 4. Extra high Extra high reasoning depth for complex problems + 3. max Maximum available reasoning +› 4. High (current) Greater reasoning depth for complex problems + 5. Extra high Extra high reasoning depth for complex problems Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index d0394464fa..d4de738314 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -212,7 +212,7 @@ impl ChatWidget { }); let reasoning_effort_override = Some( self.effective_reasoning_effort() - .or(self.config.model_reasoning_effort) + .or_else(|| self.config.model_reasoning_effort.clone()) .or(model_default_reasoning_effort), ); let rate_limit_snapshots: Vec = self @@ -382,15 +382,11 @@ impl ChatWidget { } pub(super) fn status_line_reasoning_effort_label( - effort: Option, - ) -> &'static str { + effort: Option<&ReasoningEffortConfig>, + ) -> String { match effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", - None | Some(ReasoningEffortConfig::None) => "default", + None | Some(ReasoningEffortConfig::None) => "default".to_string(), + Some(effort) => effort.as_str().to_string(), } } } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index d8facdb0cf..592dd04381 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -562,7 +562,7 @@ impl ChatWidget { match item { StatusLineItem::ModelName => Some(self.model_display_name().to_string()), StatusLineItem::ModelWithReasoning => Some(self.model_with_reasoning_display_name()), - StatusLineItem::Reasoning => Some(self.reasoning_display_name().to_string()), + StatusLineItem::Reasoning => Some(self.reasoning_display_name()), StatusLineItem::CurrentDir => { Some(format_directory_display( self.status_line_cwd(), @@ -762,15 +762,16 @@ impl ChatWidget { /*max_chars*/ 32, )), TerminalTitleItem::Reasoning => Some(Self::truncate_terminal_title_part( - self.reasoning_display_name().to_string(), + self.reasoning_display_name(), /*max_chars*/ 32, )), TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(), } } - fn reasoning_display_name(&self) -> &'static str { - Self::status_line_reasoning_effort_label(self.effective_reasoning_effort()) + fn reasoning_display_name(&self) -> String { + let effort = self.effective_reasoning_effort(); + Self::status_line_reasoning_effort_label(effort.as_ref()) } fn model_with_reasoning_display_name(&self) -> String { diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index cfc9278ded..b058b47cca 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1429,6 +1429,7 @@ pub(super) fn plugins_test_detail( needs_auth: *needs_auth, }) .collect(), + app_templates: Vec::new(), mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(), } } diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index bcc960e647..89c332a4ef 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2405,13 +2405,55 @@ async fn model_reasoning_selection_popup_snapshot() { set_chatgpt_auth(&mut chat); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); - let preset = get_available_model(&chat, "gpt-5.4"); + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset.supported_reasoning_efforts.insert( + 2, + ReasoningEffortPreset { + effort: ReasoningEffortConfig::Custom("max".to_string()), + description: "Maximum available reasoning".to_string(), + }, + ); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("model_reasoning_selection_popup", popup); } +#[tokio::test] +async fn model_reasoning_selection_popup_applies_custom_effort() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + let custom_effort = ReasoningEffortConfig::Custom("max".to_string()); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); + + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset + .supported_reasoning_efforts + .push(ReasoningEffortPreset { + effort: custom_effort.clone(), + description: "Maximum available reasoning".to_string(), + }); + chat.open_reasoning_popup(preset); + while rx.try_recv().is_ok() {} + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let selected_effort_events = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::UpdateReasoningEffort(effort) => Some((None, effort)), + AppEvent::PersistModelSelection { model, effort } => Some((Some(model), effort)), + _ => None, + }) + .collect::>(); + assert_eq!( + selected_effort_events, + vec![ + (None, Some(custom_effort.clone())), + (Some("gpt-5.4".to_string()), Some(custom_effort)), + ] + ); +} + #[tokio::test] async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs index 23e8836454..c63f6f746c 100644 --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs @@ -149,7 +149,7 @@ pub(crate) fn new_session_info( // Header box rendered as history (so it appears at the very top) let header = SessionHeaderHistoryCell::new( session.model.clone(), - session.reasoning_effort, + session.reasoning_effort.clone(), show_fast_status, config.cwd.to_path_buf(), CODEX_CLI_VERSION, @@ -317,15 +317,10 @@ impl SessionHeaderHistoryCell { formatted } - fn reasoning_label(&self) -> Option<&'static str> { - self.reasoning_effort.map(|effort| match effort { - ReasoningEffortConfig::Minimal => "minimal", - ReasoningEffortConfig::Low => "low", - ReasoningEffortConfig::Medium => "medium", - ReasoningEffortConfig::High => "high", - ReasoningEffortConfig::XHigh => "xhigh", - ReasoningEffortConfig::None => "none", - }) + fn reasoning_label(&self) -> Option<&str> { + self.reasoning_effort + .as_ref() + .map(ReasoningEffortConfig::as_str) } } @@ -368,7 +363,7 @@ impl HistoryCell for SessionHeaderHistoryCell { ]; if let Some(reasoning) = reasoning_label { spans.push(Span::from(" ")); - spans.push(Span::from(reasoning)); + spans.push(Span::from(reasoning.to_owned())); } if self.show_fast_status { spans.push(" ".into()); diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 3273c610ae..0532ea9fb5 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -182,7 +182,7 @@ pub(crate) fn spawn_request_summary(item: &ThreadItem) -> Option Some(SpawnRequestSummary { model: model.clone(), - reasoning_effort: *reasoning_effort, + reasoning_effort: reasoning_effort.clone(), }), _ => None, } diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index b8c9c16530..61ffd3da7d 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -287,7 +287,7 @@ impl StatusHistoryCell { ]; if config.model_provider.wire_api == WireApi::Responses { let effort_value = reasoning_effort_override - .unwrap_or(config.model_reasoning_effort) + .unwrap_or_else(|| config.model_reasoning_effort.clone()) .map(|effort| effort.to_string()) .unwrap_or_else(|| "none".to_string()); config_entries.push(("reasoning effort", effort_value)); diff --git a/codex-rs/utils/sandbox-summary/src/config_summary.rs b/codex-rs/utils/sandbox-summary/src/config_summary.rs index b3de5b6382..7e0f7dc4e9 100644 --- a/codex-rs/utils/sandbox-summary/src/config_summary.rs +++ b/codex-rs/utils/sandbox-summary/src/config_summary.rs @@ -25,7 +25,8 @@ pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'sta if config.model_provider.wire_api == WireApi::Responses { let reasoning_effort = config .model_reasoning_effort - .map(|effort| effort.to_string()); + .as_ref() + .map(std::string::ToString::to_string); entries.push(( "reasoning effort", reasoning_effort.unwrap_or_else(|| "none".to_string()), diff --git a/codex-rs/windows-sandbox-rs/build.rs b/codex-rs/windows-sandbox-rs/build.rs index af5aec78c6..6ba79e6c75 100644 --- a/codex-rs/windows-sandbox-rs/build.rs +++ b/codex-rs/windows-sandbox-rs/build.rs @@ -4,18 +4,16 @@ use std::path::PathBuf; const SETUP_BIN: &str = "codex-windows-sandbox-setup"; const SETUP_MANIFEST: &str = "codex-windows-sandbox-setup.manifest"; -fn main() { +fn main() -> Result<(), String> { println!("cargo:rerun-if-changed={SETUP_MANIFEST}"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { - return; + return Ok(()); } - let manifest_path = PathBuf::from( - env::var_os("CARGO_MANIFEST_DIR") - .expect("CARGO_MANIFEST_DIR should be set for build scripts"), - ) - .join(SETUP_MANIFEST); + let manifest_dir = env::var_os("CARGO_MANIFEST_DIR") + .ok_or_else(|| "CARGO_MANIFEST_DIR should be set for build scripts".to_string())?; + let manifest_path = PathBuf::from(manifest_dir).join(SETUP_MANIFEST); let manifest_path = manifest_path.display(); // Keep this scoped to the setup helper so Codex binaries that link the @@ -36,4 +34,6 @@ fn main() { } _ => {} } + + Ok(()) } diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index c1881b55c8..428b5ff6a1 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -15,6 +15,7 @@ use crate::allow::compute_allow_paths_for_permissions; use crate::helper_materialization::bundled_executable_path_for_exe; use crate::helper_materialization::helper_bin_dir; use crate::identity::sandbox_setup_is_complete; +use crate::logging::current_log_file_path; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::path_normalization::canonicalize_path; @@ -25,7 +26,6 @@ use crate::setup_error::clear_setup_error_report; use crate::setup_error::failure; use crate::setup_error::read_setup_error_report; use crate::ssh_config_dependencies::ssh_config_dependency_paths; -use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use base64::Engine; @@ -210,6 +210,18 @@ fn run_setup_refresh_inner( let json = serde_json::to_vec(&payload)?; let b64 = BASE64_STANDARD.encode(json); let exe = find_setup_exe(); + let sbx_dir = sandbox_dir(request.codex_home); + let log_path = current_log_file_path(&sbx_dir); + let cleared_report = match clear_setup_error_report(request.codex_home) { + Ok(()) => true, + Err(err) => { + log_note( + &format!("setup refresh: failed to clear setup_error.json before launch: {err}"), + Some(&sbx_dir), + ); + false + } + }; // Refresh should never request elevation; ensure verb isn't set and we don't trigger UAC. let mut cmd = Command::new(&exe); cmd.arg(&b64).stdout(Stdio::null()).stderr(Stdio::null()); @@ -221,24 +233,34 @@ fn run_setup_refresh_inner( cwd.display(), b64.len() ), - Some(&sandbox_dir(request.codex_home)), + Some(&sbx_dir), ); - let status = cmd - .status() - .map_err(|e| { - log_note( - &format!("setup refresh: failed to spawn {}: {e}", exe.display()), - Some(&sandbox_dir(request.codex_home)), - ); - e - }) - .context("spawn setup refresh")?; + let status = cmd.status().map_err(|err| { + let message = format!( + "setup refresh failed to launch helper: helper={}, cwd={}, log={}, error={err}", + exe.display(), + cwd.display(), + log_path.display() + ); + log_note(&format!("setup refresh: {message}"), Some(&sbx_dir)); + failure(SetupErrorCode::OrchestratorHelperLaunchFailed, message) + })?; if !status.success() { log_note( &format!("setup refresh: exited with status {status:?}"), - Some(&sandbox_dir(request.codex_home)), + Some(&sbx_dir), + ); + return Err(report_helper_failure( + request.codex_home, + cleared_report, + status.code(), + )); + } + if let Err(err) = clear_setup_error_report(request.codex_home) { + log_note( + &format!("setup refresh: failed to clear setup_error.json after success: {err}"), + Some(&sbx_dir), ); - return Err(anyhow!("setup refresh failed with status {status}")); } Ok(()) } @@ -1087,7 +1109,9 @@ mod tests { use crate::helper_materialization::helper_bin_dir; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::setup_error::SetupErrorCode; + use crate::setup_error::SetupErrorReport; use crate::setup_error::extract_failure; + use crate::setup_error::write_setup_error_report; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; @@ -1146,6 +1170,64 @@ mod tests { ) } + #[test] + fn report_helper_failure_uses_setup_error_report_when_clear_succeeded() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + write_setup_error_report( + codex_home.as_path(), + &SetupErrorReport { + code: super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + message: "firewall policy unavailable".to_string(), + }, + ) + .expect("write setup error report"); + + let err = super::report_helper_failure( + codex_home.as_path(), + /*cleared_report*/ true, + /*exit_code*/ Some(1), + ); + + let failure = extract_failure(&err).expect("structured setup failure"); + assert_eq!( + &super::SetupFailure::new( + super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + "firewall policy unavailable", + ), + failure + ); + } + + #[test] + fn report_helper_failure_ignores_setup_error_report_when_clear_failed() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + write_setup_error_report( + codex_home.as_path(), + &SetupErrorReport { + code: super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + message: "stale report".to_string(), + }, + ) + .expect("write setup error report"); + + let err = super::report_helper_failure( + codex_home.as_path(), + /*cleared_report*/ false, + /*exit_code*/ Some(1), + ); + + let failure = extract_failure(&err).expect("structured setup failure"); + assert_eq!( + &super::SetupFailure::new( + super::SetupErrorCode::OrchestratorHelperExitNonzero, + "setup helper exited with status Some(1)", + ), + failure + ); + } + #[test] fn setup_refresh_skips_profiles_without_managed_filesystem_permissions() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/setup_error.rs b/codex-rs/windows-sandbox-rs/src/setup_error.rs index 0f759ef879..d9104602bc 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_error.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_error.rs @@ -117,7 +117,7 @@ pub struct SetupErrorReport { pub message: String, } -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub struct SetupFailure { pub code: SetupErrorCode, pub message: String, diff --git a/patches/v8_bazel_rules.patch b/patches/v8_bazel_rules.patch index 1c49d06b09..97e07d9d47 100644 --- a/patches/v8_bazel_rules.patch +++ b/patches/v8_bazel_rules.patch @@ -106,6 +106,55 @@ index 9648e4a..88efd41 100644 }) + select({ ":should_add_rdynamic": ["-rdynamic"], "//conditions:default": [], +@@ -459,6 +488,11 @@ + def _mksnapshot(ctx): + prefix = ctx.attr.prefix + suffix = ctx.attr.suffix ++ # Windows cross-builds use Linux for the exec configuration, but the ++ # snapshot generator must match the target ABI and run on the Windows ++ # runner. Action strategies only choose where an action runs; they cannot ++ # change this executable from the exec to the target configuration. ++ tool = ctx.executable.target_tool if ctx.attr.target_os == "win" else ctx.executable.tool + outs = [ + ctx.actions.declare_file(prefix + "/snapshot" + suffix + ".cc"), + ctx.actions.declare_file(prefix + "/embedded" + suffix + ".S"), +@@ -476,7 +510,7 @@ + outs[1].path, + ] + ctx.attr.args, +- executable = ctx.executable.tool, ++ executable = tool, + progress_message = "Running mksnapshot", + ) + return [DefaultInfo(files = depset(outs))] +@@ -491,6 +525,12 @@ + executable = True, + cfg = "exec", + ), ++ "target_tool": attr.label( ++ mandatory = True, ++ allow_files = True, ++ executable = True, ++ cfg = "target", ++ ), + "target_os": attr.string(mandatory = True), + "prefix": attr.string(mandatory = True), + "suffix": attr.string(mandatory = True), +@@ -504,6 +544,7 @@ + args = args, + prefix = "noicu", + tool = ":noicu/mksnapshot" + suffix, ++ target_tool = ":noicu/mksnapshot" + suffix, + suffix = suffix, + target_os = select({ + "@v8//bazel/config:is_macos": "mac", +@@ -516,6 +557,7 @@ + args = args, + prefix = "icu", + tool = ":icu/mksnapshot" + suffix, ++ target_tool = ":icu/mksnapshot" + suffix, + suffix = suffix, + target_os = select({ + "@v8//bazel/config:is_macos": "mac", diff --git a/orig/v8-14.6.202.11/BUILD.bazel b/mod/v8-14.6.202.11/BUILD.bazel index 421ebcd..52283ea 100644 --- a/orig/v8-14.6.202.11/BUILD.bazel @@ -309,7 +358,7 @@ index 421ebcd..52283ea 100644 ], ) -@@ -4772,9 +4784,15 @@ v8_binary( +@@ -4772,9 +4784,20 @@ v8_binary( ":icu/generated_torque_initializers", ":icu/v8_initializers_files", ], @@ -317,11 +366,16 @@ index 421ebcd..52283ea 100644 + # external-reference helpers together while producing the snapshot, the + # final embedder binary may not fold the same pair and startup + # deserialization will reject the snapshot. ++ # GN also increases the x64 Windows stack reserve because constructing the ++ # snapshot overflows the linker's 1 MiB default. linkopts = select({ "@v8//bazel/config:is_android": ["-llog"], - "//conditions:default": [], + "@v8//bazel/config:is_macos": ["-Wl,-no_deduplicate"], -+ "@v8//bazel/config:is_windows": ["/OPT:NOICF"], ++ "@v8//bazel/config:is_windows": [ ++ "-Wl,/OPT:NOICF", ++ "-Wl,/STACK:2097152", ++ ], + "//conditions:default": ["-Wl,--icf=none"], }), noicu_deps = [":v8_libshared_noicu"],