mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Merge 7ea8e231d0 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+12
-12
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+2
-98
@@ -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 `<stdin>` 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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -157,6 +157,7 @@ pub(crate) struct ThreadInitializedEventParams {
|
||||
pub(crate) initialization_mode: ThreadInitializationMode,
|
||||
pub(crate) subagent_source: Option<String>,
|
||||
pub(crate) parent_thread_id: Option<String>,
|
||||
pub(crate) forked_from_thread_id: Option<String>,
|
||||
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 {
|
||||
|
||||
@@ -348,6 +348,7 @@ pub struct SubAgentThreadStartedInput {
|
||||
pub session_id: String,
|
||||
pub thread_id: String,
|
||||
pub parent_thread_id: Option<String>,
|
||||
pub forked_from_thread_id: Option<String>,
|
||||
pub product_client_id: String,
|
||||
pub client_name: String,
|
||||
pub client_version: String,
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
|
||||
+2
-9
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+75
-12
@@ -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"
|
||||
}
|
||||
}
|
||||
+72
-9
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+2
-9
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+2
-9
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+2
-9
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+2
-9
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+2
-9
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>, reason: AppTemplateUnavailableReason | null, };
|
||||
+5
@@ -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";
|
||||
@@ -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<SkillSummary>, hooks: Array<PluginHookSummary>, apps: Array<AppSummary>, mcpServers: Array<string>, };
|
||||
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array<SkillSummary>, hooks: Array<PluginHookSummary>, apps: Array<AppSummary>, appTemplates: Array<AppTemplateSummary>, mcpServers: Array<string>, };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -642,9 +642,32 @@ pub struct PluginDetail {
|
||||
pub skills: Vec<SkillSummary>,
|
||||
pub hooks: Vec<PluginHookSummary>,
|
||||
pub apps: Vec<AppSummary>,
|
||||
pub app_templates: Vec<AppTemplateSummary>,
|
||||
pub mcp_servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub canonical_connector_id: Option<String>,
|
||||
pub logo_url: Option<String>,
|
||||
pub logo_url_dark: Option<String>,
|
||||
pub materialized_app_ids: Vec<String>,
|
||||
pub reason: Option<AppTemplateUnavailableReason>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -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.<id>]` 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 <feature_name> > 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 <feature_name> > 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`).
|
||||
|
||||
@@ -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::<TomlValue>(&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<Vec<String>> {
|
||||
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}")))?;
|
||||
|
||||
@@ -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::<ExternalAgentConfigMigrationItem>::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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -62,10 +62,10 @@ fn reasoning_efforts_from_preset(
|
||||
efforts: Vec<ReasoningEffortPreset>,
|
||||
) -> Vec<ReasoningEffortOption> {
|
||||
efforts
|
||||
.iter()
|
||||
.into_iter()
|
||||
.map(|preset| ReasoningEffortOption {
|
||||
reasoning_effort: preset.effort,
|
||||
description: preset.description.to_string(),
|
||||
description: preset.description,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<OutgoingMessageSender>,
|
||||
config_manager: ConfigManager,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
analytics_events_client: AnalyticsEventsClient,
|
||||
}
|
||||
@@ -73,14 +66,12 @@ impl ConfigRequestProcessor {
|
||||
pub(crate) fn new(
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
config_manager: ConfigManager,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
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<Option<ClientResponsePayload>, 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<PathBuf>,
|
||||
@@ -317,28 +238,19 @@ impl ConfigRequestProcessor {
|
||||
&self,
|
||||
params: ExperimentalFeatureEnablementSetParams,
|
||||
) -> Result<ExperimentalFeatureEnablementSetResponse, JSONRPCErrorError> {
|
||||
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() {
|
||||
|
||||
@@ -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<AppSummary>,
|
||||
) -> 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AbsolutePathBuf> {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AppListUpdatedNotification> {
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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::<ModelListResponse>(response)?;
|
||||
let mut expected_presets: Vec<ModelPreset> = 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::<Vec<_>>();
|
||||
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());
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<ThreadStartResponse>(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::<ThreadResumeResponse>(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;
|
||||
|
||||
@@ -348,7 +348,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<(
|
||||
.collect::<Vec<_>>();
|
||||
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::<ThreadStartResponse>(response)?;
|
||||
|
||||
let instruction_sources = instruction_sources
|
||||
.into_iter()
|
||||
.map(normalize_path_for_comparison)
|
||||
.collect::<Vec<_>>();
|
||||
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::<ThreadStartResponse>(response)?;
|
||||
|
||||
assert!(instruction_sources.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn normalize_path_for_comparison(path: impl AsRef<Path>) -> PathBuf {
|
||||
let path = path.as_ref();
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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<codex_core_plugins::ConfiguredMarketplace>,
|
||||
include_available: bool,
|
||||
marketplace_sources: &HashMap<String, JsonMarketplaceSource>,
|
||||
) -> 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<JsonMarketplaceSource>,
|
||||
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<JsonMarketplaceSource>,
|
||||
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<String, JsonMarketplaceSource> {
|
||||
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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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(...)`.
|
||||
|
||||
@@ -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<v8::Value>,
|
||||
) {
|
||||
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::<RuntimeState>() {
|
||||
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<Option<String>, String> {
|
||||
let object = v8::Local::<v8::Object>::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,
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ReasoningEffortConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<ReasoningSummaryConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<ReasoningContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default, Clone, PartialEq)]
|
||||
@@ -239,11 +249,6 @@ pub struct ResponseCreateWsRequest {
|
||||
pub client_metadata: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResponseProcessedWsRequest {
|
||||
pub response_id: String,
|
||||
}
|
||||
|
||||
pub fn response_create_client_metadata(
|
||||
client_metadata: Option<HashMap<String, String>>,
|
||||
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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<codex_features::FeatureToml<
|
||||
codex_features::CodeModeConfigToml,
|
||||
>>(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if feature.id == codex_features::Feature::MultiAgentV2 {
|
||||
validation.properties.insert(
|
||||
feature.key.to_string(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -167,6 +167,27 @@ pub struct RemotePluginDetail {
|
||||
pub app_manifest: Option<JsonValue>,
|
||||
pub skills: Vec<RemotePluginSkill>,
|
||||
pub app_ids: Vec<String>,
|
||||
pub app_templates: Vec<RemoteAppTemplate>,
|
||||
pub mcp_servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteAppTemplate {
|
||||
pub template_id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub canonical_connector_id: Option<String>,
|
||||
pub logo_url: Option<String>,
|
||||
pub logo_url_dark: Option<String>,
|
||||
pub materialized_app_ids: Vec<String>,
|
||||
pub reason: Option<RemoteAppTemplateUnavailableReason>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
app_manifest: Option<JsonValue>,
|
||||
#[serde(default, alias = "unavailable_app_templates")]
|
||||
app_templates: Vec<RemoteAppTemplateResponse>,
|
||||
#[serde(default)]
|
||||
keywords: Vec<String>,
|
||||
interface: RemotePluginReleaseInterfaceResponse,
|
||||
#[serde(default)]
|
||||
skills: Vec<RemotePluginSkillResponse>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
mcp_servers: Vec<RemotePluginMcpServerResponse>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
canonical_connector_id: Option<String>,
|
||||
#[serde(default)]
|
||||
logo_url: Option<String>,
|
||||
#[serde(default)]
|
||||
logo_url_dark: Option<String>,
|
||||
#[serde(default)]
|
||||
materialized_app_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
reason: Option<RemoteAppTemplateUnavailableReason>,
|
||||
}
|
||||
|
||||
#[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::<Vec<_>>();
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<File, String> {
|
||||
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<String, String> {
|
||||
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,
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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");
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+135
-70
@@ -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<String>,
|
||||
) -> Option<String> {
|
||||
let fs = environment?.get_filesystem();
|
||||
) -> Option<LoadedAgentsMd> {
|
||||
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<String>,
|
||||
) -> Option<String> {
|
||||
) -> Option<LoadedAgentsMd> {
|
||||
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<AbsolutePathBuf> {
|
||||
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<String>,
|
||||
) -> io::Result<Option<String>> {
|
||||
) -> io::Result<Option<LoadedAgentsMd>> {
|
||||
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<String> = 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<InstructionEntry>,
|
||||
}
|
||||
|
||||
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<String>) -> 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<Item = &AbsolutePathBuf> {
|
||||
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],
|
||||
|
||||
@@ -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<String> {
|
||||
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<Vec<AbsolutePathBuf>> {
|
||||
@@ -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<_>>(), 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<_>>(),
|
||||
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<_>>(),
|
||||
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<_>>(),
|
||||
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.
|
||||
|
||||
+13
-21
@@ -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<Reasoning> {
|
||||
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<Head
|
||||
/// Meant to be called just before sending the request over the socket, to capture realistic
|
||||
/// transport timing.
|
||||
fn stamp_ws_stream_request_start_ms(request: &mut ResponsesWsRequest) {
|
||||
let ResponsesWsRequest::ResponseCreate(payload) = request else {
|
||||
return;
|
||||
};
|
||||
let ResponsesWsRequest::ResponseCreate(payload) = request;
|
||||
payload
|
||||
.client_metadata
|
||||
.get_or_insert_with(HashMap::new)
|
||||
|
||||
@@ -70,6 +70,7 @@ pub struct ThreadConfigSnapshot {
|
||||
pub personality: Option<Personality>,
|
||||
pub collaboration_mode: CollaborationMode,
|
||||
pub session_source: SessionSource,
|
||||
pub forked_from_thread_id: Option<ThreadId>,
|
||||
pub parent_thread_id: Option<ThreadId>,
|
||||
pub thread_source: Option<ThreadSource>,
|
||||
}
|
||||
@@ -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<AbsolutePathBuf> {
|
||||
self.codex.instruction_sources().await
|
||||
}
|
||||
|
||||
pub async fn config(&self) -> Arc<crate::config::Config> {
|
||||
self.codex.session.get_config().await
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TokenUsage>,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<_>>(),
|
||||
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<_>>(),
|
||||
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::<ConfigToml>(
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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<String>,
|
||||
pub user_instructions: Option<LoadedAgentsMd>,
|
||||
|
||||
/// Base instructions override.
|
||||
pub base_instructions: Option<String>,
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
|
||||
@@ -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<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
file_system_sandbox_policy: FileSystemSandboxPolicy,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
prefix_rule: Option<Vec<String>>,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
base_instructions: Option<String>,
|
||||
user_instructions: Option<String>,
|
||||
user_instructions: Option<LoadedAgentsMd>,
|
||||
compact_prompt: Option<String>,
|
||||
cwd: AbsolutePathBuf,
|
||||
mcp_servers: Constrained<HashMap<String, McpServerConfig>>,
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user