Compare commits

..

65 Commits

Author SHA1 Message Date
jif-oai
cbbe3acdf4 feat: add feed char tool 2025-12-08 15:35:47 +00:00
gameofby
98923654d0 fix: refine the warning message and docs for deprecated tools config (#7685)
Issue #7661 revealed that users are confused by deprecation warnings
like:
> `tools.web_search` is deprecated. Use `web_search_request` instead.

This message misleadingly suggests renaming the config key from
`web_search` to `web_search_request`, when the actual required change is
to **move and rename the configuration from the `[tools]` section to the
`[features]` section**.

This PR clarifies the warning messages and documentation to make it
clear that deprecated `[tools]` configurations should be moved to
`[features]`. Changes made:
- Updated deprecation warning format in `codex-rs/core/src/codex.rs:520`
to include `[features].` prefix
- Updated corresponding test expectations in
`codex-rs/core/tests/suite/deprecation_notice.rs:39`
- Improved documentation in `docs/config.md` to clarify upfront that
`[tools]` options are deprecated in favor of `[features]`
2025-12-08 01:23:21 -08:00
Robby He
57ba9fa100 fix(doc): TOML otel exporter example — multi-line inline table is inv… (#7669)
…alid (#7668)

The `otel` exporter example in `docs/config.md` is misleading and will
cause
the configuration parser to fail if copied verbatim.

Summary
-------
The example uses a TOML inline table but spreads the inline-table braces
across multiple lines. TOML inline tables must be contained on a single
line
(`key = { a = 1, b = 2 }`); placing newlines inside the braces triggers
a
parse error in most TOML parsers and prevents Codex from starting.

Reproduction
------------
1. Paste the snippet below into `~/.codex/config.toml` (or your project
config).
2. Run `codex` (or the command that loads the config).
3. The process will fail to start with a TOML parse error similar to:

```text
Error loading config.toml: TOML parse error at line 55, column 27
   |
55 | exporter = { otlp-http = {
   |                           ^
newlines are unsupported in inline tables, expected nothing
```

Problematic snippet (as currently shown in the docs)
---------------------------------------------------
```toml
[otel]
exporter = { otlp-http = {
  endpoint = "https://otel.example.com/v1/logs",
  protocol = "binary",
  headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }
}}
```

Recommended fixes
------------------
```toml
[otel.exporter."otlp-http"]
endpoint = "https://otel.example.com/v1/logs"
protocol = "binary"

[otel.exporter."otlp-http".headers]
"x-otlp-api-key" = "${OTLP_TOKEN}"
```

Or, keep an inline table but write it on one line (valid but less
readable):

```toml
[otel]
exporter = { "otlp-http" = { endpoint = "https://otel.example.com/v1/logs", protocol = "binary", headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } } }
```
2025-12-08 01:20:23 -08:00
Eric Traut
acb8ed493f Fixed regression for chat endpoint; missing tools name caused litellm proxy to crash (#7724)
This PR addresses https://github.com/openai/codex/issues/7051
2025-12-08 00:49:51 -08:00
Ahmed Ibrahim
53a486f7ea Add remote models feature flag (#7648)
# External (non-OpenAI) Pull Request Requirements

Before opening this Pull Request, please read the dedicated
"Contributing" markdown file or your PR may be closed:
https://github.com/openai/codex/blob/main/docs/contributing.md

If your PR conforms to our contribution guidelines, replace this text
with a detailed and high quality description of your changes.

Include a link to a bug report or enhancement request.
2025-12-07 09:47:48 -08:00
Michael Bolin
3c3d3d1adc fix: add integration tests for codex-exec-mcp-server with execpolicy (#7617)
This PR introduces integration tests that run
[codex-shell-tool-mcp](https://www.npmjs.com/package/@openai/codex-shell-tool-mcp)
as a user would. Note that this requires running our fork of Bash, so we
introduce a [DotSlash](https://dotslash-cli.com/) file for `bash` so
that we can run the integration tests on multiple platforms without
having to check the binaries into the repository. (As noted in the
DotSlash file, it is slightly more heavyweight than necessary, which may
be worth addressing as disk space in CI is limited:
https://github.com/openai/codex/pull/7678.)

To start, this PR adds two tests:

- `list_tools()` makes the `list_tools` request to the MCP server and
verifies we get the expected response
- `accept_elicitation_for_prompt_rule()` defines a `prefix_rule()` with
`decision="prompt"` and verifies the elicitation flow works as expected

Though the `accept_elicitation_for_prompt_rule()` test **only works on
Linux**, as this PR reveals that there are currently issues when running
the Bash fork in a read-only sandbox on Linux. This will have to be
fixed in a follow-up PR.

Incidentally, getting this test run to correctly on macOS also requires
a recent fix we made to `brew` that hasn't hit a mainline release yet,
so getting CI green in this PR required
https://github.com/openai/codex/pull/7680.
2025-12-07 06:39:38 +00:00
Michael Bolin
3c087e8fda fix: ensure macOS CI runners for Rust tests include recent Homebrew fixes (#7680)
As noted in the code comment, we introduced a key fix for `brew` in
https://github.com/Homebrew/brew/pull/21157 that Codex needs, but it has
not hit stable yet, so we update our CI job to use latest `brew` from
`origin/main`.

This is necessary for the new integration tests introduced in
https://github.com/openai/codex/pull/7617.
2025-12-06 22:11:07 -08:00
Michael Bolin
7386e2efbc fix: clear out space on ubuntu runners before running Rust tests (#7678)
When I put up https://github.com/openai/codex/pull/7617 for review,
initially I started seeing failures on the `ubuntu-24.04` runner used
for Rust test runs for the `x86_64-unknown-linux-gnu` architecture. Chat
suggested a number of things that could be removed to save space, which
seems to help.
2025-12-06 21:46:07 -08:00
Victor
b2cb05d562 docs: point dev checks to just (#7673)
Update install and contributing guides to use the root justfile helpers
(`just fmt`, `just fix -p <crate>`, and targeted tests) instead of the
older cargo fmt/clippy/test instructions that have been in place since
459363e17b. This matches the justfile relocation to the repo root in
952d6c946 and the current lint/test workflow for CI (see
`.github/workflows/rust-ci.yml`).
2025-12-06 18:57:08 -08:00
Jay Sabva
9a74228c66 docs: Remove experimental_use_rmcp_client from config (#7672)
Removed experimental Rust MCP client option from config.
2025-12-06 16:51:07 -08:00
Jay Sabva
315b1e957d docs: fix documentation of rmcp client flag (#7665)
## Summary
- Updated the rmcp client flag's documentation in config.md file
- changed it from `experimental_use_rmcp_client` to `rmcp_client`
2025-12-06 10:17:18 -08:00
Michael Bolin
82090803d9 fix: exec-server stream was erroring for large requests (#7654)
Previous to this change, large `EscalateRequest` payloads exceeded the
kernel send buffer, causing our single `sendmsg(2)` call (with attached
FDs) to be split and retried without proper control handling; this led
to `EINVAL`/broken pipe in the
`handle_escalate_session_respects_run_in_sandbox_decision()` test when
using an `env` with large contents.

**Before:** `AsyncSocket::send_with_fds()` called `send_json_message()`,
which called `send_message_bytes()`, which made one `socket.sendmsg()`
call followed by additional `socket.send()` calls, as necessary:


2e4a402521/codex-rs/exec-server/src/posix/socket.rs (L198-L209)

**After:** `AsyncSocket::send_with_fds()` now calls
`send_stream_frame()`, which calls `send_stream_chunk()` one or more
times. Each call to `send_stream_chunk()` calls `socket.sendmsg()`.

In the previous implementation, the subsequent `socket.send()` writes
had no control information associated with them, whereas in the new
`send_stream_chunk()` implementation, a fresh `MsgHdr` (using
`with_control()`, as appropriate) is created for `socket.sendmsg()` each
time.

Additionally, with this PR, stream sending attaches `SCM_RIGHTS` only on
the first chunk, and omits control data when there are no FDs, allowing
oversized payloads to deliver correctly while preserving FD limits and
error checks.
2025-12-06 10:16:47 -08:00
Alexander
f521d29726 fix: OTEL HTTP exporter panic and mTLS support (#7651)
This fixes two issues with the OTEL HTTP exporter:

1. **Runtime panic with async reqwest client**

The `opentelemetry_sdk` `BatchLogProcessor` spawns a dedicated OS thread
that uses `futures_executor::block_on()` rather than tokio's runtime.
When the async reqwest client's timeout mechanism calls
`tokio::time::sleep()`, it panics with "there is no reactor running,
must be called from the context of a Tokio 1.x runtime".

The fix is to use `reqwest::blocking::Client` instead, which doesn't
depend on tokio for timeouts. However, the blocking client creates its
own internal tokio runtime during construction, which would panic if
built from within an async context. We wrap the construction in
`tokio::task::block_in_place()` to handle this.

2. **mTLS certificate handling**

The HTTP client wasn't properly configured for mTLS, matching the fixes
previously done for the model provider client:

- Added `.tls_built_in_root_certs(false)` when using a custom CA
certificate to ensure only our CA is trusted
- Added `.https_only(true)` when using client identity
- Added `rustls-tls` feature to ensure rustls is used (required for
`Identity::from_pem()` to work correctly)
2025-12-05 20:46:44 -08:00
xl-openai
93f61dbc5f Also load skills from repo root. (#7645)
Also load skills from /REPO_ROOT/codex/skills.
2025-12-05 18:01:49 -08:00
Dylan Hurd
6c9c563faf fix(apply-patch): preserve CRLF line endings on Windows (#7515)
## Summary
This PR is heavily based on #4017, which contains the core logic for the
fix. To reduce the risk, we are first introducing it only on windows. We
can then expand to wsl / other environments as needed, and then tackle
net new files.

## Testing
- [x] added unit tests in apply-patch
- [x] add integration tests to apply_patch_cli.rs

---------

Co-authored-by: Chase Naples <Cnaples79@gmail.com>
2025-12-05 16:43:27 -08:00
Josh McKinney
952d6c9465 Move justfile to repository root (#7652)
## Summary
- move the workspace justfile to the repository root for easier
discovery
- set the just working directory to codex-rs so existing recipes still
run in the Rust workspace

## Testing
- not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_i_69334db473108329b0cc253b7fd8218e)
2025-12-05 16:24:55 -08:00
Jeremy Rose
2e4a402521 cloud: status, diff, apply (#7614)
Adds cli commands for getting the status of cloud tasks, and for
getting/applying the diffs from same.
2025-12-05 21:39:23 +00:00
Pavel Krymets
f48d88067e Fix unified_exec on windows (#7620)
Fix unified_exec on windows

Requires removal of PSUEDOCONSOLE_INHERIT_CURSOR flag so child processed
don't attempt to wait for cursor position response (and timeout).


https://github.com/wezterm/wezterm/compare/main...pakrym:wezterm:PSUEDOCONSOLE_INHERIT_CURSOR?expand=1

---------

Co-authored-by: pakrym-oai <pakrym@openai.com>
2025-12-05 20:09:43 +00:00
Dylan Hurd
a8cbbdbc6e feat(core) Add login to shell_command tool (#6846)
## Summary
Adds the `login` parameter to the `shell_command` tool - optional,
defaults to true.

## Testing
- [x] Tested locally
2025-12-05 11:03:25 -08:00
Ahmed Ibrahim
d08efb1743 Wire with_remote_overrides to construct model families (#7621)
- This PR wires `with_remote_overrides` and make the
`construct_model_families` an async function
- Moves getting model family a level above to keep the function `sync`
- Updates the tests to local, offline, and `sync` helper for model
families
2025-12-05 10:40:15 -08:00
jif-oai
5f80ad6da8 fix: chat completion with parallel tool call (#7634) 2025-12-05 10:20:36 -08:00
jif-oai
e91bb6b947 fix: ignore ghost snapshots in token consumption (#7638) 2025-12-05 13:57:24 +00:00
zhao-oai
b8eab7ce90 fix: taking plan type from usage endpoint instead of thru auth token (#7610)
pull plan type from the usage endpoint, persist it in session state /
tui state, and propagate through rate limit snapshots
2025-12-04 23:34:13 -08:00
zhao-oai
b1c918d8f7 feat: exec policy integration in shell mcp (#7609)
adding execpolicy support into the `posix` mcp

Co-authored-by: Michael Bolin <mbolin@openai.com>
2025-12-04 21:55:54 -08:00
zhao-oai
4c9762d15c fix typo (#7626) 2025-12-04 21:48:15 -08:00
Ahmed Ibrahim
7b359c9c8e Call models endpoint in models manager (#7616)
- Introduce `with_remote_overrides` and update
`refresh_available_models`
- Put `auth_manager` instead of `auth_mode` on `models_manager`
- Remove `ShellType` and `ReasoningLevel` to use already existing
structs
2025-12-04 18:28:03 -08:00
jif-oai
6736d1828d fix: sse for chat (#7594) 2025-12-04 16:46:56 -08:00
Dylan Hurd
073a8533b8 chore(apply-patch) scenarios for e2e testing (#7567)
## Summary
This PR introduces an End to End test suite for apply-patch, so we can
easily validate behavior against other implementations as well.

## Testing
- [x] These are tests
2025-12-05 00:20:54 +00:00
Michael Bolin
0972cd9404 chore: refactor to move Arc<RwLock> concern outside exec_policy_for (#7615)
The caller should decide whether wrapping the policy in `Arc<RwLock>` is
necessary. This should make https://github.com/openai/codex/pull/7609 a
bit smoother.

- `exec_policy_for()` -> `load_exec_policy_for_features()`
- introduce `load_exec_policy()` that does not take `Features` as an arg
- both return `Result<Policy, ExecPolicyError>` instead of
Result<Arc<RwLock<Policy>>, ExecPolicyError>`

This simplifies the tests as they have no need for `Arc<RwLock>`.
2025-12-04 15:13:27 -08:00
Robby He
28dcdb566a Fix handle_shortcut_overlay_key for cross-platform consistency (#7583)
**Summary**
- Shortcut toggle using `?` in `handle_shortcut_overlay_key` fails to
trigger on some platforms (notably Windows). Current match requires
`KeyCode::Char('?')` with `KeyModifiers::NONE`. Some terminals set
`SHIFT` when producing `?` (since it is typically `Shift + /`), so the
strict `NONE` check prevents toggling.

**Impact**
- On Windows consoles/terminals, pressing `?` with an empty composer
often does nothing, leading to inconsistent UX compared to macOS/Linux.

**Root Cause**
- Crossterm/terminal backends report modifiers inconsistently across
platforms. Generating `?` may include `SHIFT`. The code enforces
`modifiers == NONE`, so valid `?` presses with `SHIFT` are ignored.
AltGr keyboards may also surface as `ALT`.

**Repro Steps**
- Open the TUI, ensure the composer is empty.
- Press `?`.
- Expected: Shortcut overlay toggles.
- Actual (Windows frequently): No toggle occurs.

**Fix Options**
- Option 1 (preferred): Accept `?` regardless of `SHIFT`, but reject
`CONTROL` and `ALT`.
- Rationale: Keeps behavior consistent across platforms with minimal
code change.
	- Example change:
		- Before: matching `KeyModifiers::NONE` only.
		- After: allow `SHIFT`, disallow `CONTROL | ALT`.
		- Suggested condition:
			```rust
			let toggles = matches!(key_event.code, KeyCode::Char('?'))
&& !key_event.modifiers.intersects(KeyModifiers::CONTROL |
KeyModifiers::ALT)
					&& self.is_empty();
			```

- Option 2: Platform-specific handling (Windows vs non-Windows).
- Implement two variants or conditional branches using `#[cfg(target_os
= "windows")]`.
- On Windows, accept `?` with `SHIFT`; on other platforms, retain
current behavior.
- Trade-off: Higher maintenance burden and code divergence for limited
benefit.

---

close #5495
2025-12-04 14:56:58 -08:00
Owen Lin
e8f6d65899 fix(app-server): add will_retry to ErrorNotification (#7611)
VSCE renders `codex/event/stream_error` (automatically retried, e.g.
`"Reconnecting... 1/n"`) and `codex/event/error` (terminal errors)
differently, so add `will_retry` on ErrorNotification to indicate this.
2025-12-04 21:48:37 +00:00
Owen Lin
342c084cc3 fix(app-server): add duration_ms to McpToolCallItem (#7605)
Seems like a nice field to have, and also VSCE does render this one.
2025-12-04 13:45:07 -08:00
Ahmed Ibrahim
903b7774bc Add models endpoint (#7603)
- Use the codex-api crate to introduce models endpoint. 
- Add `models` to codex core tests helpers
- Add `ModelsInfo` for the endpoint return type
2025-12-04 12:57:54 -08:00
Ahmed Ibrahim
6e6338aa87 Inline response recording and remove process_items indirection (#7310)
- Inline response recording during streaming: `run_turn` now records
items as they arrive instead of building a `ProcessedResponseItem` list
and post‑processing via `process_items`.
- Simplify turn handling: `handle_output_item_done` returns the
follow‑up signal + optional tool future; `needs_follow_up` is set only
there, and in‑flight tool futures are drained once at the end (errors
logged, no extra state writes).
- Flattened stream loop: removed `process_items` indirection and the
extra output queue
- - Tests: relaxed `tool_parallelism::tool_results_grouped` to allow any
completion order while still requiring matching call/output IDs.
2025-12-04 12:17:54 -08:00
Jeremy Rose
7dfc3a4dc7 add --branch to codex cloud exec (#7602)
Adds `--branch` to `codex cloud exec` to set base branch.
2025-12-04 12:00:18 -08:00
Ahmed Ibrahim
9b2055586d remove model_family from `config (#7571)
- Remove `model_family` from `config`
- Make sure to still override config elements related to `model_family`
like supporting reasoning
2025-12-04 11:57:58 -08:00
Maxime Savard
ce0b38c056 FIX: WSL Paste image does not work (#6793)
## Related issues:  
- https://github.com/openai/codex/issues/3939  
- https://github.com/openai/codex/issues/2292  
- https://github.com/openai/codex/issues/7528 (After correction
https://github.com/openai/codex/pull/3990)

**Area:** `codex-cli` (image handling / clipboard & file uploads)  
**Platforms affected:** WSL (Ubuntu on Windows 10/11). No behavior
change on native Linux/macOS/Windows.

## Summary

This PR fixes image pasting and file uploads when running `codex-cli`
inside WSL. Previously, image operations failed silently or with
permission errors because paths weren't properly mapped between Windows
and WSL filesystems.

## Visual Result

<img width="1118" height="798" alt="image"
src="https://github.com/user-attachments/assets/14e10bc4-6b71-4d1f-b2a6-52c0a67dd069"
/>

## Last Rust-Cli

<img width="1175" height="859" alt="image"
src="https://github.com/user-attachments/assets/7ef41e29-9118-42c9-903c-7116d21e1751"
/>

## Root cause

The CLI assumed native Linux/Windows environments and didn't handle the
WSL↔Windows boundary:

- Used Linux paths for files that lived on the Windows host
- Missing path normalization between Windows (`C:\...`) and WSL
(`/mnt/c/...`)
- Clipboard access failed under WSL

### Why `Ctrl+V` doesn't work in WSL terminals

Most WSL terminal emulators (Windows Terminal, ConEmu, etc.) intercept
`Ctrl+V` at the terminal level to paste text from the Windows clipboard.
This keypress never reaches the CLI application itself, so our clipboard
image handler never gets triggered. Users need `Ctrl+Alt+V`.

## Changes

### WSL detection & path mapping

- Detects WSL by checking `/proc/sys/kernel/osrelease` and the
`WSL_INTEROP` env var
- Maps Windows drive paths to WSL mount paths (`C:\...` → `/mnt/c/...`)

### Clipboard fallback for WSL

- When clipboard access fails under WSL, falls back to PowerShell to
extract images from the Windows clipboard
- Saves to a temp file and maps the path back to WSL

### UI improvements

- Shows `Ctrl+Alt+V` hint on WSL (many terminals intercept plain
`Ctrl+V`)
- Better error messages for unreadable images

## Performance

- Negligible overhead. The fallback adds a single FS copy to a temp file
only when needed.
- Direct streaming remains the default.

## Files changed

- `protocol/src/lib.rs` – Added platform detection module  
- `protocol/src/models.rs` – Added WSL path mapping for local images  
- `protocol/src/platform.rs` – New module with WSL detection utilities  
- `tui/src/bottom_pane/chat_composer.rs` – Added base64 data URL support
and WSL path mapping
- `tui/src/bottom_pane/footer.rs` – WSL-aware keyboard shortcuts  
- `tui/src/clipboard_paste.rs` – PowerShell clipboard fallback

## How to reproduce the original bug (pre-fix)

1. Run `codex-cli` inside WSL2 on Windows.  
2. Paste an image from the Windows clipboard or drag an image from
`C:\...` into the terminal.
3. Observe that the image is not attached (silent failure) or an error
is logged; no artifact reaches the tool.

## How to verify the fix

1. Build this branch and run `codex-cli` inside WSL2.  
2. Paste from clipboard and drag from both Windows and WSL paths.  
3. Confirm that the image appears in the tool and the CLI shows a single
concise info line (no warning unless fallback was used).

I’m happy to adjust paths, naming, or split helpers into a separate
module if you prefer.

## How to try this branch

If you want to try this before it’s merged, you can use my Git branch:

Repository: https://github.com/Waxime64/codex.git  
Branch: `wsl-image-2`

1. Start WSL on your Windows machine.
2. Clone the repository and switch to the branch:
   ```bash
   git clone https://github.com/Waxime64/codex.git
   cd codex
   git checkout wsl-image-2
   # then go into the Rust workspace root, e.g.:
   cd codex-rs
3. Build the TUI binary:
  cargo build -p codex-tui --bin codex-tui --release
4. Install the binary:
   sudo install -m 0755 target/release/codex-tui /usr/local/bin/codex
5. From the project directory where you want to use Codex, start it
with:
   cd /path/to/your/project
   /usr/local/bin/codex

On WSL, use CTRL+ALT+V to paste an image from the Windows clipboard into
the chat.
2025-12-04 10:50:20 -08:00
Dylan Hurd
37c36024c7 chore(core): test apply_patch_cli on Windows (#7554)
## Summary
These tests pass on windows, let's enable them.

## Testing
- [x] These are more tests
2025-12-04 10:39:45 -08:00
jif-oai
291b54a762 chore: review in read-only (#7593) 2025-12-04 10:01:12 -08:00
jif-oai
2b5d0b2935 feat: update sandbox policy to allow TTY (#7580)
**Change**: Seatbelt now allows file-ioctl on /dev/ttys[0-9]+ even
without the sandbox extension so pre-created PTYs remain interactive
(Python REPL, shells).

**Risk**: A seatbelted process that already holds a PTY fd (including
one it shouldn’t) could issue tty ioctls like TIOCSTI or termios changes
on that fd. This doesn’t allow opening new PTYs or reading/writing them;
it only broadens ioctl capability on existing fds.

**Why acceptable**: We already hand the child its PTY for interactive
use; restoring ioctls is required for isatty() and prompts to work. The
attack requires being given or inheriting a sensitive PTY fd; by design
we don’t hand untrusted processes other users’ PTYs (we don't hand them
any PTYs actually), so the practical exposure is limited to the PTY
intentionally allocated for the session.

**Validation**:
Running
```
start a python interpreter and keep it running
```
Followed by:
* `calculate 1+1 using it` -> works as expected
* `Use this Python session to run the command just fix in
/Users/jif/code/codex/codex-rs` -> does not work as expected
2025-12-04 17:58:58 +00:00
zhao-oai
404a1ea34b Update execpolicy.md (#7595) 2025-12-04 17:55:42 +00:00
jif-oai
36edb412b1 fix: release session ID when not used (#7592) 2025-12-04 17:42:16 +00:00
jif-oai
1b2509f05a chore: default warning messages to true (#7588) 2025-12-04 17:29:23 +00:00
pakrym-oai
f1b7cdc3bd Use shared check sandboxing (#7547) 2025-12-04 08:34:09 -08:00
pakrym-oai
c4e18f1b63 Slightly better status display for unified exec (#7563)
Trim bash -lc
2025-12-04 08:32:54 -08:00
jif-oai
8f4e00e1f1 chore: tool tip for /prompt (#7591) 2025-12-04 15:13:49 +00:00
zhao-oai
87666695ba execpolicy tui flow (#7543)
## Updating the `execpolicy` TUI flow

In the TUI, when going through the command approval flow, codex will now
ask the user if they would like to whitelist the FIRST unmatched command
among a chain of commands.

For example, let's say the agent wants to run `apple | pear` with an
empty `execpolicy`

Neither apple nor pear will match to an `execpolicy` rule. Thus, when
prompting the user, codex tui will ask the user if they would like to
whitelist `apple`.

If the agent wants to run `apple | pear` again, they would be prompted
again because pear is still unknown. when prompted, the user will now be
asked if they'd like to whitelist `pear`.

Here's a demo video of this flow:


https://github.com/user-attachments/assets/fd160717-f6cb-46b0-9f4a-f0a974d4e710

This PR also removed the `allow for this session` option from the TUI.
2025-12-04 07:58:13 +00:00
ae
871f44f385 Add Enterprise plan to ChatGPT login description (#6918)
## Summary
- update ChatGPT onboarding login description to mention Enterprise
plans alongside Plus, Pro, and Team

## Testing
- just fmt


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_i_691e088daf20832c88d8b667adf45128)
2025-12-03 23:47:46 -08:00
zhao-oai
3d35cb4619 Refactor execpolicy fallback evaluation (#7544)
## Refactor of the `execpolicy` crate

To illustrate why we need this refactor, consider an agent attempting to
run `apple | rm -rf ./`. Suppose `apple` is allowed by `execpolicy`.
Before this PR, `execpolicy` would consider `apple` and `pear` and only
render one rule match: `Allow`. We would skip any heuristics checks on
`rm -rf ./` and immediately approve `apple | rm -rf ./` to run.

To fix this, we now thread a `fallback` evaluation function into
`execpolicy` that runs when no `execpolicy` rules match a given command.
In our example, we would run `fallback` on `rm -rf ./` and prevent
`apple | rm -rf ./` from being run without approval.
2025-12-03 23:39:48 -08:00
zhao-oai
e925a380dc whitelist command prefix integration in core and tui (#7033)
this PR enables TUI to approve commands and add their prefixes to an
allowlist:
<img width="708" height="605" alt="Screenshot 2025-11-21 at 4 18 07 PM"
src="https://github.com/user-attachments/assets/56a19893-4553-4770-a881-becf79eeda32"
/>

note: we only show the option to whitelist the command when 
1) command is not multi-part (e.g `git add -A && git commit -m 'hello
world'`)
2) command is not already matched by an existing rule
2025-12-03 23:17:02 -08:00
Jeremy Rose
ccdeb9d9c4 use markdown for rendering tips (#7557)
## Summary
- render tooltip content through the markdown renderer and prepend a
bold Tip label
- wrap tooltips at the available width using the indent’s measured width
before adding the indent

## Testing
- `/root/.cargo/bin/just fmt`
- `RUSTFLAGS="--cfg tokio_unstable" TOKIO_UNSTABLE=1
/root/.cargo/bin/just fix -p codex-tui` *(fails: codex-tui tests
reference tokio::time::advance/start_paused gated behind the tokio
test-util feature)*
- `RUSTFLAGS="--cfg tokio_unstable" TOKIO_UNSTABLE=1 cargo test -p
codex-tui` *(fails: codex-tui tests reference
tokio::time::advance/start_paused gated behind the tokio test-util
feature)*

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_i_693081406050832c9772ae9fa5dd77ca)
2025-12-04 04:58:35 +00:00
Ahmed Ibrahim
67e67e054f Migrate codex max (#7566)
- make codex max the default
- fix: we were doing some async work in sync function which caused tui
to panic
2025-12-03 20:54:48 -08:00
Eric Traut
edd98dd3b7 Remove test from #7481 that doesn't add much value (#7558)
Follow-up from PR #7481
2025-12-03 19:10:54 -08:00
Celia Chen
3e6cd5660c [app-server] make file_path for config optional (#7560)
When we are writing to config using `config/value/write` or
`config/batchWrite`, it always require a `config/read` before it right
now in order to get the correct file path to write to. make this
optional so we read from the default user config file if this is not
passed in.
2025-12-04 03:08:18 +00:00
Ahmed Ibrahim
cee37a32b2 Migrate model family to models manager (#7565)
This PR moves `ModelsFamily` to `openai_models`. It also propagates
`ModelsManager` to session services and use it to drive model family. We
also make `derive_default_model_family` private because it's a step
towards what we want: one place that gives model configuration.

This is a second step at having one source of truth for models
information and config: `ModelsManager`.

Next steps would be to remove `ModelsFamily` from config. That's massive
because it's being used in 41 occasions mostly pre launching `codex`.
Also, we need to make `find_family_for_model` private. It's also big
because it's being used in 21 occasions ~ all tests.
2025-12-03 18:49:47 -08:00
Ahmed Ibrahim
8da91d1c89 Migrate tui to use models manager (#7555)
- This PR treats the `ModelsManager` like `AuthManager` and propagate it
into the tui, replacing the `builtin_model_presets`
- We are also decreasing the visibility of `builtin_model_presets`

based on https://github.com/openai/codex/pull/7552
2025-12-03 18:00:47 -08:00
Ahmed Ibrahim
00cc00ead8 Introduce ModelsManager and migrate app-server to use it. (#7552) 2025-12-03 17:17:56 -08:00
muyuanjin
70b97790be fix: wrap long exec lines in transcript overlay (#7481)
What
-----
- Fix the Ctrl+T transcript overlay so that very long exec output lines
are soft‑wrapped to the viewport width instead of being rendered as a
single truncated row.
- Add a regression test to `TranscriptOverlay` to ensure long exec
outputs are rendered on multiple lines in the overlay.

Why
----
- Previously, the transcript overlay rendered extremely long single exec
lines as one on‑screen row and simply cut them off at the right edge,
with no horizontal scrolling.
- This made it impossible to inspect the full content of long tool/exec
outputs in the transcript view, even though the main TUI view already
wrapped those lines.
- Fixes #7454.

How
----
- Update `ExecCell::transcript_lines` to wrap exec output lines using
the existing `RtOptions`/`word_wrap_line` helpers so that transcript
rendering is width‑aware.
- Reuse the existing line utilities to expand the wrapped `Line` values
into the transcript overlay, preserving styling while respecting the
current viewport width.
- Add `transcript_overlay_wraps_long_exec_output_lines` test in
`pager_overlay.rs` that constructs a long single‑line exec output,
renders the transcript overlay into a small buffer, and asserts that the
long marker string spans multiple rendered lines.
2025-12-03 16:45:08 -08:00
Michael Bolin
1cfc967eb8 fix: Features should be immutable over the lifetime of a session/thread (#7540)
I noticed that `features: Features` was defined on `struct
SessionConfiguration`, which is commonly owned by `SessionState`, which
is in turn owned by `Session`.

Though I do not believe that `Features` should be allowed to be modified
over the course of a session (if the feature state is not invariant, it
makes it harder to reason about), which argues that it should live on
`Session` rather than `SessionState` or `SessionConfiguration`.

This PR moves `Features` to `Session` and updates all call sites. It
appears the only place we were mutating `Features` was:

- in tests
- the sub-agent config for a review task:


3ef76ff29d/codex-rs/core/src/tasks/review.rs (L86-L89)

Note this change also means it is no longer an `async` call to check the
state of a feature, eliminating the possibility of a
[TOCTTOU](https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use)
error between checking the state of a feature and acting on it:


3ef76ff29d/codex-rs/core/src/codex.rs (L1069-L1076)
2025-12-03 16:12:31 -08:00
xl-openai
9a50a04400 feat: Support listing and selecting skills via $ or /skills (#7506)
List/Select skills with $-mention or /skills
2025-12-03 15:12:46 -08:00
Owen Lin
231ff19ca2 [app-server] fix: add thread_id to turn/plan/updated (#7553)
Realized we're missing this while migrating VSCE.
2025-12-03 15:00:07 -08:00
Aofei Sheng
de08c735a6 feat(tui): map Ctrl-P/N to arrow navigation in textarea (#7530)
- Treat Ctrl-P/N (and their C0 fallbacks) the same as Up/Down so cursor
movement matches popup/history behavior and control bytes never land in
the buffer

Fixes #7529

Signed-off-by: Aofei Sheng <aofei@aofeisheng.com>
2025-12-03 14:43:31 -08:00
muyuanjin
3395ebd96e fix(tui): limit user shell output by screen lines (#7448)
What
- Limit the TUI "user shell" output panel by the number of visible
screen lines rather than by the number of logical lines.
- Apply middle truncation after wrapping, so a few extremely long lines
cannot expand into hundreds of visible lines.
- Add a regression test to guard this behavior.

Why
When the `ExecCommandSource::UserShell` tool returns a small number of
very long logical lines, the TUI wraps those lines into many visual
lines. The existing truncation logic applied
`USER_SHELL_TOOL_CALL_MAX_LINES` to the number of logical lines *before*
wrapping.

As a result, a command like:

- `Ran bash -lc "grep -R --line-number 'maskAssetId' ."`

or a synthetic command that prints a single ~50,000‑character line, can
produce hundreds of screen lines and effectively flood the viewport. The
intended middle truncation for user shell output does not take effect in
this scenario.

How
- In `codex-rs/tui/src/exec_cell/render.rs`, change the `ExecCell`
rendering path for `ExecCommandSource::UserShell` so that:
- Each logical line from `CommandOutput::aggregated_output` is first
wrapped via `word_wrap_line` into multiple screen lines using the
appropriate `RtOptions` and width from the `EXEC_DISPLAY_LAYOUT`
configuration.
- `truncate_lines_middle` is then applied to the wrapped screen lines,
with `USER_SHELL_TOOL_CALL_MAX_LINES` as the limit. This means the limit
is enforced on visible screen lines, not logical lines.
- The existing layout struct (`ExecDisplayLayout`) continues to provide
`output_max_lines`, so user shell output is subject to both
`USER_SHELL_TOOL_CALL_MAX_LINES` and the layout-specific
`output_max_lines` constraint.
- Keep using `USER_SHELL_TOOL_CALL_MAX_LINES` as the cap, but interpret
it as a per‑tool‑call limit on screen lines.
- Add a regression test `user_shell_output_is_limited_by_screen_lines`
in `codex-rs/tui/src/exec_cell/render.rs` that:
- Constructs two extremely long logical lines containing a short marker
(`"Z"`), so each wrapped screen line still contains the marker.
  - Wraps them at a narrow width to generate many screen lines.
- Asserts that the unbounded wrapped output would exceed
`USER_SHELL_TOOL_CALL_MAX_LINES` screen lines.
- Renders an `ExecCell` for `ExecCommandSource::UserShell` at the same
width and counts rendered lines containing the marker.
- Asserts `output_screen_lines <= USER_SHELL_TOOL_CALL_MAX_LINES`,
guarding against regressions where truncation happens before wrapping.

This change keeps user shell output readable while ensuring it cannot
flood the TUI, even when the tool emits a few extremely long lines.

Tests
- `cargo test -p codex-tui`

Issue
- Fixes #7447
2025-12-03 13:43:17 -08:00
Ahmed Ibrahim
71504325d3 Migrate model preset (#7542)
- Introduce `openai_models` in `/core`
- Move `PRESETS` under it
- Move `ModelPreset`, `ModelUpgrade`, `ReasoningEffortPreset`,
`ReasoningEffortPreset`, and `ReasoningEffortPreset` to `protocol`
- Introduce `Op::ListModels` and `EventMsg::AvailableModels`

Next steps:
- migrate `app-server` and `tui` to use the introduced Operation
2025-12-03 20:30:43 +00:00
jif-oai
7f068cfbcc fix: main (#7546) 2025-12-03 20:15:12 +00:00
228 changed files with 8041 additions and 1742 deletions

View File

@@ -369,6 +369,57 @@ jobs:
steps:
- uses: actions/checkout@v6
# We have been running out of space when running this job on Linux for
# x86_64-unknown-linux-gnu, so remove some unnecessary dependencies.
- name: Remove unnecessary dependencies to save space
if: ${{ startsWith(matrix.runner, 'ubuntu') }}
shell: bash
run: |
set -euo pipefail
sudo rm -rf \
/usr/local/lib/android \
/usr/share/dotnet \
/usr/local/share/boost \
/usr/local/lib/node_modules \
/opt/ghc
sudo apt-get remove -y docker.io docker-compose podman buildah
# Ensure brew includes this fix so that brew's shellenv.sh loads
# cleanly in the Codex sandbox (it is frequently eval'd via .zprofile
# for Brew users, including the macOS runners on GitHub):
#
# https://github.com/Homebrew/brew/pull/21157
#
# Once brew 5.0.5 is released and is the default on macOS runners, this
# step can be removed.
- name: Upgrade brew
if: ${{ startsWith(matrix.runner, 'macos') }}
shell: bash
run: |
set -euo pipefail
brew --version
git -C "$(brew --repo)" fetch origin
git -C "$(brew --repo)" checkout main
git -C "$(brew --repo)" reset --hard origin/main
export HOMEBREW_UPDATE_TO_TAG=0
brew update
brew upgrade
brew --version
# Some integration tests rely on DotSlash being installed.
# See https://github.com/openai/codex/pull/7617.
- name: Install DotSlash
uses: facebook/install-dotslash@v2
- name: Pre-fetch DotSlash artifacts
# The Bash wrapper is not available on Windows.
if: ${{ !startsWith(matrix.runner, 'windows') }}
shell: bash
run: |
set -euo pipefail
dotslash -- fetch exec-server/tests/suite/bash
- uses: dtolnay/rust-toolchain@1.90
with:
targets: ${{ matrix.target }}

View File

@@ -75,6 +75,7 @@ If you dont have the tool:
### Test assertions
- Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already.
- Prefer deep equals comparisons whenever possible. Perform `assert_eq!()` on entire objects, rather than individual fields.
### Integration tests (core)

52
codex-rs/Cargo.lock generated
View File

@@ -858,6 +858,7 @@ dependencies = [
"http",
"pretty_assertions",
"regex-lite",
"reqwest",
"serde",
"serde_json",
"thiserror 2.0.17",
@@ -865,6 +866,7 @@ dependencies = [
"tokio-test",
"tokio-util",
"tracing",
"wiremock",
]
[[package]]
@@ -1046,7 +1048,7 @@ dependencies = [
"pretty_assertions",
"regex-lite",
"serde_json",
"supports-color",
"supports-color 3.0.2",
"tempfile",
"tokio",
"toml",
@@ -1086,10 +1088,13 @@ dependencies = [
"codex-login",
"codex-tui",
"crossterm",
"owo-colors",
"pretty_assertions",
"ratatui",
"reqwest",
"serde",
"serde_json",
"supports-color 3.0.2",
"tokio",
"tokio-stream",
"tracing",
@@ -1117,12 +1122,10 @@ name = "codex-common"
version = "0.0.0"
dependencies = [
"clap",
"codex-app-server-protocol",
"codex-core",
"codex-lmstudio",
"codex-ollama",
"codex-protocol",
"once_cell",
"serde",
"toml",
]
@@ -1237,7 +1240,7 @@ dependencies = [
"serde",
"serde_json",
"shlex",
"supports-color",
"supports-color 3.0.2",
"tempfile",
"tokio",
"tracing",
@@ -1253,10 +1256,14 @@ name = "codex-exec-server"
version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"async-trait",
"clap",
"codex-core",
"codex-execpolicy",
"exec_server_test_support",
"libc",
"maplit",
"path-absolutize",
"pretty_assertions",
"rmcp",
@@ -1269,6 +1276,7 @@ dependencies = [
"tokio-util",
"tracing",
"tracing-subscriber",
"which",
]
[[package]]
@@ -1610,7 +1618,7 @@ dependencies = [
"shlex",
"strum 0.27.2",
"strum_macros 0.27.2",
"supports-color",
"supports-color 3.0.2",
"tempfile",
"textwrap 0.16.2",
"tokio",
@@ -2497,6 +2505,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "exec_server_test_support"
version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"codex-core",
"rmcp",
"serde_json",
"tokio",
]
[[package]]
name = "eyre"
version = "0.6.12"
@@ -2556,8 +2576,7 @@ dependencies = [
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
source = "git+https://github.com/pakrym/wezterm?branch=PSUEDOCONSOLE_INHERIT_CURSOR#fe38df8409545a696909aa9a09e63438630f217d"
dependencies = [
"libc",
"thiserror 1.0.69",
@@ -4433,6 +4452,10 @@ name = "owo-colors"
version = "4.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e"
dependencies = [
"supports-color 2.1.0",
"supports-color 3.0.2",
]
[[package]]
name = "parking"
@@ -4631,8 +4654,7 @@ dependencies = [
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
source = "git+https://github.com/pakrym/wezterm?branch=PSUEDOCONSOLE_INHERIT_CURSOR#fe38df8409545a696909aa9a09e63438630f217d"
dependencies = [
"anyhow",
"bitflags 1.3.2",
@@ -4641,7 +4663,7 @@ dependencies = [
"lazy_static",
"libc",
"log",
"nix 0.28.0",
"nix 0.29.0",
"serial2",
"shared_library",
"shell-words",
@@ -6169,6 +6191,16 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "supports-color"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89"
dependencies = [
"is-terminal",
"is_ci",
]
[[package]]
name = "supports-color"
version = "3.0.2"

View File

@@ -47,7 +47,7 @@ members = [
resolver = "2"
[workspace.package]
version = "0.65.0-alpha.5"
version = "0.0.0"
# Track the edition for all workspace crates in one place. Individual
# crates can still override this value, but keeping it here means new
# crates created with `cargo new -w ...` automatically inherit the 2024
@@ -96,6 +96,7 @@ codex-utils-readiness = { path = "utils/readiness" }
codex-utils-string = { path = "utils/string" }
codex-windows-sandbox = { path = "windows-sandbox-rs" }
core_test_support = { path = "core/tests/common" }
exec_server_test_support = { path = "exec-server/tests/common" }
mcp-types = { path = "mcp-types" }
mcp_test_support = { path = "mcp-server/tests/common" }
@@ -178,8 +179,8 @@ seccompiler = "0.5.0"
sentry = "0.34.0"
serde = "1"
serde_json = "1"
serde_yaml = "0.9"
serde_with = "3.16"
serde_yaml = "0.9"
serial_test = "3.2.0"
sha1 = "0.10.6"
sha2 = "0.10"
@@ -288,6 +289,7 @@ opt-level = 0
# Uncomment to debug local changes.
# ratatui = { path = "../../ratatui" }
crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" }
portable-pty = { git = "https://github.com/pakrym/wezterm", branch = "PSUEDOCONSOLE_INHERIT_CURSOR" }
ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" }
# Uncomment to debug local changes.

View File

@@ -3,11 +3,11 @@ use std::path::PathBuf;
use codex_protocol::ConversationId;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Verbosity;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;

View File

@@ -4,11 +4,11 @@ use std::path::PathBuf;
use crate::protocol::common::AuthMode;
use codex_protocol::account::PlanType;
use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand;
use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg;
use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus;
@@ -209,6 +209,8 @@ pub struct OverriddenMetadata {
pub struct ConfigWriteResponse {
pub status: WriteStatus,
pub version: String,
/// Canonical path to the config file that was written.
pub file_path: String,
pub overridden_metadata: Option<OverriddenMetadata>,
}
@@ -245,10 +247,11 @@ pub struct ConfigReadResponse {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigValueWriteParams {
pub file_path: String,
pub key_path: String,
pub value: JsonValue,
pub merge_strategy: MergeStrategy,
/// Path to the config file to write; defaults to the user's `config.toml` when omitted.
pub file_path: Option<String>,
pub expected_version: Option<String>,
}
@@ -256,8 +259,9 @@ pub struct ConfigValueWriteParams {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigBatchWriteParams {
pub file_path: String,
pub edits: Vec<ConfigEdit>,
/// Path to the config file to write; defaults to the user's `config.toml` when omitted.
pub file_path: Option<String>,
pub expected_version: Option<String>,
}
@@ -938,6 +942,9 @@ pub struct TurnError {
#[ts(export_to = "v2/")]
pub struct ErrorNotification {
pub error: TurnError,
// Set to true if the error is transient and the app-server process will automatically retry.
// If true, this will not interrupt a turn.
pub will_retry: bool,
pub thread_id: String,
pub turn_id: String,
}
@@ -1137,6 +1144,9 @@ pub enum ThreadItem {
arguments: JsonValue,
result: Option<McpToolCallResult>,
error: Option<McpToolCallError>,
/// The duration of the MCP tool call in milliseconds.
#[ts(type = "number | null")]
duration_ms: Option<i64>,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
@@ -1294,6 +1304,7 @@ pub struct TurnDiffUpdatedNotification {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct TurnPlanUpdatedNotification {
pub thread_id: String,
pub turn_id: String,
pub explanation: Option<String>,
pub plan: Vec<TurnPlanStep>,
@@ -1513,6 +1524,7 @@ pub struct RateLimitSnapshot {
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub plan_type: Option<PlanType>,
}
impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
@@ -1521,6 +1533,7 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
primary: value.primary.map(RateLimitWindow::from),
secondary: value.secondary.map(RateLimitWindow::from),
credits: value.credits.map(CreditsSnapshot::from),
plan_type: value.plan_type,
}
}
}

View File

@@ -179,6 +179,7 @@ pub(crate) async fn apply_bespoke_event_handling(
cwd,
reason,
risk,
proposed_execpolicy_amendment: _,
parsed_cmd,
}) => match api_version {
ApiVersion::V1 => {
@@ -332,6 +333,7 @@ pub(crate) async fn apply_bespoke_event_handling(
outgoing
.send_server_notification(ServerNotification::Error(ErrorNotification {
error: turn_error,
will_retry: false,
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
}))
@@ -347,6 +349,7 @@ pub(crate) async fn apply_bespoke_event_handling(
outgoing
.send_server_notification(ServerNotification::Error(ErrorNotification {
error: turn_error,
will_retry: true,
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
}))
@@ -661,6 +664,7 @@ pub(crate) async fn apply_bespoke_event_handling(
}
EventMsg::PlanUpdate(plan_update_event) => {
handle_turn_plan_update(
conversation_id,
&event_turn_id,
plan_update_event,
api_version,
@@ -693,6 +697,7 @@ async fn handle_turn_diff(
}
async fn handle_turn_plan_update(
conversation_id: ConversationId,
event_turn_id: &str,
plan_update_event: UpdatePlanArgs,
api_version: ApiVersion,
@@ -700,6 +705,7 @@ async fn handle_turn_plan_update(
) {
if let ApiVersion::V2 = api_version {
let notification = TurnPlanUpdatedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.to_string(),
explanation: plan_update_event.explanation,
plan: plan_update_event
@@ -1174,6 +1180,7 @@ async fn construct_mcp_tool_call_notification(
arguments: begin_event.invocation.arguments.unwrap_or(JsonValue::Null),
result: None,
error: None,
duration_ms: None,
};
ItemStartedNotification {
thread_id,
@@ -1193,6 +1200,7 @@ async fn construct_mcp_tool_call_end_notification(
} else {
McpToolCallStatus::Failed
};
let duration_ms = i64::try_from(end_event.duration.as_millis()).ok();
let (result, error) = match &end_event.result {
Ok(value) => (
@@ -1218,6 +1226,7 @@ async fn construct_mcp_tool_call_end_notification(
arguments: end_event.invocation.arguments.unwrap_or(JsonValue::Null),
result,
error,
duration_ms,
};
ItemCompletedNotification {
thread_id,
@@ -1422,7 +1431,16 @@ mod tests {
],
};
handle_turn_plan_update("turn-123", update, ApiVersion::V2, &outgoing).await;
let conversation_id = ConversationId::new();
handle_turn_plan_update(
conversation_id,
"turn-123",
update,
ApiVersion::V2,
&outgoing,
)
.await;
let msg = rx
.recv()
@@ -1430,6 +1448,7 @@ mod tests {
.ok_or_else(|| anyhow!("should send one notification"))?;
match msg {
OutgoingMessage::AppServerNotification(ServerNotification::TurnPlanUpdated(n)) => {
assert_eq!(n.thread_id, conversation_id.to_string());
assert_eq!(n.turn_id, "turn-123");
assert_eq!(n.explanation.as_deref(), Some("need plan"));
assert_eq!(n.plan.len(), 2);
@@ -1480,6 +1499,7 @@ mod tests {
unlimited: false,
balance: Some("5".to_string()),
}),
plan_type: None,
};
handle_token_count_event(
@@ -1584,6 +1604,7 @@ mod tests {
arguments: serde_json::json!({"server": ""}),
result: None,
error: None,
duration_ms: None,
},
};
@@ -1737,6 +1758,7 @@ mod tests {
arguments: JsonValue::Null,
result: None,
error: None,
duration_ms: None,
},
};
@@ -1790,6 +1812,7 @@ mod tests {
structured_content: None,
}),
error: None,
duration_ms: Some(0),
},
};
@@ -1831,6 +1854,7 @@ mod tests {
error: Some(McpToolCallError {
message: "boom".to_string(),
}),
duration_ms: Some(1),
},
};

View File

@@ -1862,8 +1862,7 @@ impl CodexMessageProcessor {
async fn list_models(&self, request_id: RequestId, params: ModelListParams) {
let ModelListParams { limit, cursor } = params;
let auth_mode = self.auth_manager.auth().map(|auth| auth.mode);
let models = supported_models(auth_mode);
let models = supported_models(self.conversation_manager.clone()).await;
let total = models.len();
if total == 0 {

View File

@@ -109,12 +109,17 @@ impl ConfigApi {
async fn apply_edits(
&self,
file_path: String,
file_path: Option<String>,
expected_version: Option<String>,
edits: Vec<(String, JsonValue, MergeStrategy)>,
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
let allowed_path = self.codex_home.join(CONFIG_FILE_NAME);
if !paths_match(&allowed_path, &file_path) {
let provided_path = file_path
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| allowed_path.clone());
if !paths_match(&allowed_path, &provided_path) {
return Err(config_write_error(
ConfigWriteErrorCode::ConfigLayerReadonly,
"Only writes to the user config are allowed",
@@ -190,9 +195,16 @@ impl ConfigApi {
.map(|_| WriteStatus::OkOverridden)
.unwrap_or(WriteStatus::Ok);
let file_path = provided_path
.canonicalize()
.unwrap_or(provided_path.clone())
.display()
.to_string();
Ok(ConfigWriteResponse {
status,
version: updated_layers.user.version.clone(),
file_path,
overridden_metadata: overridden,
})
}
@@ -587,15 +599,14 @@ fn canonical_json(value: &JsonValue) -> JsonValue {
}
}
fn paths_match(expected: &Path, provided: &str) -> bool {
let provided_path = PathBuf::from(provided);
fn paths_match(expected: &Path, provided: &Path) -> bool {
if let (Ok(expanded_expected), Ok(expanded_provided)) =
(expected.canonicalize(), provided_path.canonicalize())
(expected.canonicalize(), provided.canonicalize())
{
return expanded_expected == expanded_provided;
}
expected == provided_path
expected == provided
}
fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> {
@@ -795,7 +806,7 @@ mod tests {
let result = api
.write_value(ConfigValueWriteParams {
file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(),
file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()),
key_path: "approval_policy".to_string(),
value: json!("never"),
merge_strategy: MergeStrategy::Replace,
@@ -832,7 +843,7 @@ mod tests {
let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]);
let error = api
.write_value(ConfigValueWriteParams {
file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(),
file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()),
key_path: "model".to_string(),
value: json!("gpt-5"),
merge_strategy: MergeStrategy::Replace,
@@ -852,6 +863,30 @@ mod tests {
);
}
#[tokio::test]
async fn write_value_defaults_to_user_config_path() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "").unwrap();
let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]);
api.write_value(ConfigValueWriteParams {
file_path: None,
key_path: "model".to_string(),
value: json!("gpt-new"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect("write succeeds");
let contents =
std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config");
assert!(
contents.contains("model = \"gpt-new\""),
"config.toml should be updated even when file_path is omitted"
);
}
#[tokio::test]
async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
let tmp = tempdir().expect("tempdir");
@@ -872,7 +907,7 @@ mod tests {
let error = api
.write_value(ConfigValueWriteParams {
file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(),
file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()),
key_path: "approval_policy".to_string(),
value: json!("bogus"),
merge_strategy: MergeStrategy::Replace,
@@ -957,7 +992,7 @@ mod tests {
let result = api
.write_value(ConfigValueWriteParams {
file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(),
file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()),
key_path: "approval_policy".to_string(),
value: json!("on-request"),
merge_strategy: MergeStrategy::Replace,

View File

@@ -1,12 +1,15 @@
use codex_app_server_protocol::AuthMode;
use std::sync::Arc;
use codex_app_server_protocol::Model;
use codex_app_server_protocol::ReasoningEffortOption;
use codex_common::model_presets::ModelPreset;
use codex_common::model_presets::ReasoningEffortPreset;
use codex_common::model_presets::builtin_model_presets;
use codex_core::ConversationManager;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffortPreset;
pub fn supported_models(auth_mode: Option<AuthMode>) -> Vec<Model> {
builtin_model_presets(auth_mode)
pub async fn supported_models(conversation_manager: Arc<ConversationManager>) -> Vec<Model> {
conversation_manager
.list_models()
.await
.into_iter()
.map(model_from_preset)
.collect()
@@ -27,7 +30,7 @@ fn model_from_preset(preset: ModelPreset) -> Model {
}
fn reasoning_efforts_from_preset(
efforts: &'static [ReasoningEffortPreset],
efforts: Vec<ReasoningEffortPreset>,
) -> Vec<ReasoningEffortOption> {
efforts
.iter()

View File

@@ -16,6 +16,9 @@ use tracing::warn;
use crate::error_code::INTERNAL_ERROR_CODE;
#[cfg(test)]
use codex_protocol::account::PlanType;
/// Sends messages to the client and manages request callbacks.
pub(crate) struct OutgoingMessageSender {
next_request_id: AtomicI64,
@@ -230,6 +233,7 @@ mod tests {
}),
secondary: None,
credits: None,
plan_type: Some(PlanType::Plus),
},
});
@@ -245,7 +249,8 @@ mod tests {
"resetsAt": 123
},
"secondary": null,
"credits": null
"credits": null,
"planType": "plus"
}
},
}),

View File

@@ -23,10 +23,10 @@ use codex_app_server_protocol::SendUserTurnResponse;
use codex_app_server_protocol::ServerRequest;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningEffort;
use codex_core::protocol_config_types::ReasoningSummary;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;

View File

@@ -10,10 +10,10 @@ use codex_app_server_protocol::Tools;
use codex_app_server_protocol::UserSavedConfig;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Verbosity;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::Path;

View File

@@ -206,7 +206,7 @@ model = "gpt-old"
let write_id = mcp
.send_config_value_write_request(ConfigValueWriteParams {
file_path: codex_home.path().join("config.toml").display().to_string(),
file_path: None,
key_path: "model".to_string(),
value: json!("gpt-new"),
merge_strategy: MergeStrategy::Replace,
@@ -219,8 +219,16 @@ model = "gpt-old"
)
.await??;
let write: ConfigWriteResponse = to_response(write_resp)?;
let expected_file_path = codex_home
.path()
.join("config.toml")
.canonicalize()
.unwrap()
.display()
.to_string();
assert_eq!(write.status, WriteStatus::Ok);
assert_eq!(write.file_path, expected_file_path);
assert!(write.overridden_metadata.is_none());
let verify_id = mcp
@@ -254,7 +262,7 @@ model = "gpt-old"
let write_id = mcp
.send_config_value_write_request(ConfigValueWriteParams {
file_path: codex_home.path().join("config.toml").display().to_string(),
file_path: Some(codex_home.path().join("config.toml").display().to_string()),
key_path: "model".to_string(),
value: json!("gpt-new"),
merge_strategy: MergeStrategy::Replace,
@@ -288,7 +296,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
let batch_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: codex_home.path().join("config.toml").display().to_string(),
file_path: Some(codex_home.path().join("config.toml").display().to_string()),
edits: vec![
ConfigEdit {
key_path: "sandbox_mode".to_string(),
@@ -314,6 +322,14 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
.await??;
let batch_write: ConfigWriteResponse = to_response(batch_resp)?;
assert_eq!(batch_write.status, WriteStatus::Ok);
let expected_file_path = codex_home
.path()
.join("config.toml")
.canonicalize()
.unwrap()
.display()
.to_string();
assert_eq!(batch_write.file_path, expected_file_path);
let read_id = mcp
.send_config_read_request(ConfigReadParams {

View File

@@ -11,7 +11,7 @@ use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::ReasoningEffortOption;
use codex_app_server_protocol::RequestId;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;

View File

@@ -11,6 +11,7 @@ use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::RequestId;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_protocol::account::PlanType as AccountPlanType;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::Path;
@@ -153,6 +154,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: Some(secondary_reset_timestamp),
}),
credits: None,
plan_type: Some(AccountPlanType::Pro),
},
};
assert_eq!(received, expected);

View File

@@ -30,8 +30,8 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::protocol_config_types::ReasoningEffort;
use codex_core::protocol_config_types::ReasoningSummary;
use codex_protocol::openai_models::ReasoningEffort;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use std::path::Path;

View File

@@ -699,13 +699,7 @@ fn derive_new_contents_from_chunks(
}
};
let mut original_lines: Vec<String> = original_contents.split('\n').map(String::from).collect();
// Drop the trailing empty element that results from the final newline so
// that line counts match the behaviour of standard `diff`.
if original_lines.last().is_some_and(String::is_empty) {
original_lines.pop();
}
let original_lines: Vec<String> = build_lines_from_contents(&original_contents);
let replacements = compute_replacements(&original_lines, path, chunks)?;
let new_lines = apply_replacements(original_lines, &replacements);
@@ -713,13 +707,67 @@ fn derive_new_contents_from_chunks(
if !new_lines.last().is_some_and(String::is_empty) {
new_lines.push(String::new());
}
let new_contents = new_lines.join("\n");
let new_contents = build_contents_from_lines(&original_contents, &new_lines);
Ok(AppliedPatch {
original_contents,
new_contents,
})
}
// TODO(dylan-hurd-oai): I think we can migrate to just use `contents.lines()`
// across all platforms.
fn build_lines_from_contents(contents: &str) -> Vec<String> {
if cfg!(windows) {
contents.lines().map(String::from).collect()
} else {
let mut lines: Vec<String> = contents.split('\n').map(String::from).collect();
// Drop the trailing empty element that results from the final newline so
// that line counts match the behaviour of standard `diff`.
if lines.last().is_some_and(String::is_empty) {
lines.pop();
}
lines
}
}
fn build_contents_from_lines(original_contents: &str, lines: &[String]) -> String {
if cfg!(windows) {
// for now, only compute this if we're on Windows.
let uses_crlf = contents_uses_crlf(original_contents);
if uses_crlf {
lines.join("\r\n")
} else {
lines.join("\n")
}
} else {
lines.join("\n")
}
}
/// Detects whether the source file uses Windows CRLF line endings consistently.
/// We only consider a file CRLF-formatted if every newline is part of a
/// CRLF sequence. This avoids rewriting an LF-formatted file that merely
/// contains embedded sequences of "\r\n".
///
/// Returns `true` if the file uses CRLF line endings, `false` otherwise.
fn contents_uses_crlf(contents: &str) -> bool {
let bytes = contents.as_bytes();
let mut n_newlines = 0usize;
let mut n_crlf = 0usize;
for i in 0..bytes.len() {
if bytes[i] == b'\n' {
n_newlines += 1;
if i > 0 && bytes[i - 1] == b'\r' {
n_crlf += 1;
}
}
}
n_newlines > 0 && n_crlf == n_newlines
}
/// Compute a list of replacements needed to transform `original_lines` into the
/// new lines, given the patch `chunks`. Each replacement is returned as
/// `(start_index, old_len, new_lines)`.
@@ -1359,6 +1407,72 @@ PATCH"#,
assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n");
}
/// Ensure CRLF line endings are preserved for updated files on Windowsstyle inputs.
#[cfg(windows)]
#[test]
fn test_preserve_crlf_line_endings_on_update() {
let dir = tempdir().unwrap();
let path = dir.path().join("crlf.txt");
// Original file uses CRLF (\r\n) endings.
std::fs::write(&path, b"a\r\nb\r\nc\r\n").unwrap();
// Replace `b` -> `B` and append `d`.
let patch = wrap_patch(&format!(
r#"*** Update File: {}
@@
a
-b
+B
@@
c
+d
*** End of File"#,
path.display()
));
let mut stdout = Vec::new();
let mut stderr = Vec::new();
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
let out = std::fs::read(&path).unwrap();
// Expect all CRLF endings; count occurrences of CRLF and ensure there are 4 lines.
let content = String::from_utf8_lossy(&out);
assert!(content.contains("\r\n"));
// No bare LF occurrences immediately preceding a non-CR: the text should not contain "a\nb".
assert!(!content.contains("a\nb"));
// Validate exact content sequence with CRLF delimiters.
assert_eq!(content, "a\r\nB\r\nc\r\nd\r\n");
}
/// Ensure CRLF inputs with embedded carriage returns in the content are preserved.
#[cfg(windows)]
#[test]
fn test_preserve_crlf_embedded_carriage_returns_on_append() {
let dir = tempdir().unwrap();
let path = dir.path().join("crlf_cr_content.txt");
// Original file: first line has a literal '\r' in the content before the CRLF terminator.
std::fs::write(&path, b"foo\r\r\nbar\r\n").unwrap();
// Append a new line without modifying existing ones.
let patch = wrap_patch(&format!(
r#"*** Update File: {}
@@
+BAZ
*** End of File"#,
path.display()
));
let mut stdout = Vec::new();
let mut stderr = Vec::new();
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
let out = std::fs::read(&path).unwrap();
// CRLF endings must be preserved and the extra CR in "foo\r\r" must not be collapsed.
assert_eq!(out.as_slice(), b"foo\r\r\nbar\r\nBAZ\r\n");
}
#[test]
fn test_pure_addition_chunk_followed_by_removal() {
let dir = tempdir().unwrap();
@@ -1544,6 +1658,37 @@ PATCH"#,
assert_eq!(expected, diff);
}
/// For LF-only inputs with a trailing newline ensure that the helper used
/// on Windows-style builds drops the synthetic trailing empty element so
/// replacements behave like standard `diff` line numbering.
#[test]
fn test_derive_new_contents_lf_trailing_newline() {
let dir = tempdir().unwrap();
let path = dir.path().join("lf_trailing_newline.txt");
fs::write(&path, "foo\nbar\n").unwrap();
let patch = wrap_patch(&format!(
r#"*** Update File: {}
@@
foo
-bar
+BAR
"#,
path.display()
));
let patch = parse_patch(&patch).unwrap();
let chunks = match patch.hunks.as_slice() {
[Hunk::UpdateFile { chunks, .. }] => chunks,
_ => panic!("Expected a single UpdateFile hunk"),
};
let AppliedPatch { new_contents, .. } =
derive_new_contents_from_chunks(&path, chunks).unwrap();
assert_eq!(new_contents, "foo\nBAR\n");
}
#[test]
fn test_unified_diff_insert_at_eof() {
// Insert a new line at endoffile.

View File

@@ -0,0 +1 @@
** text eol=lf

View File

@@ -0,0 +1 @@
This is a new file

View File

@@ -0,0 +1,4 @@
*** Begin Patch
*** Add File: bar.md
+This is a new file
*** End Patch

View File

@@ -0,0 +1,2 @@
line1
changed

View File

@@ -0,0 +1 @@
obsolete

View File

@@ -0,0 +1,2 @@
line1
line2

View File

@@ -0,0 +1,9 @@
*** Begin Patch
*** Add File: nested/new.txt
+created
*** Delete File: delete.txt
*** Update File: modify.txt
@@
-line2
+changed
*** End Patch

View File

@@ -0,0 +1,4 @@
line1
changed2
line3
changed4

View File

@@ -0,0 +1,4 @@
line1
line2
line3
line4

View File

@@ -0,0 +1,9 @@
*** Begin Patch
*** Update File: multi.txt
@@
-line2
+changed2
@@
-line4
+changed4
*** End Patch

View File

@@ -0,0 +1 @@
unrelated file

View File

@@ -0,0 +1 @@
old content

View File

@@ -0,0 +1 @@
unrelated file

View File

@@ -0,0 +1,7 @@
*** Begin Patch
*** Update File: old/name.txt
*** Move to: renamed/dir/name.txt
@@
-old content
+new content
*** End Patch

View File

@@ -0,0 +1,2 @@
*** Begin Patch
*** End Patch

View File

@@ -0,0 +1,2 @@
line1
line2

View File

@@ -0,0 +1,2 @@
line1
line2

View File

@@ -0,0 +1,6 @@
*** Begin Patch
*** Update File: modify.txt
@@
-missing
+changed
*** End Patch

View File

@@ -0,0 +1,3 @@
*** Begin Patch
*** Delete File: missing.txt
*** End Patch

View File

@@ -0,0 +1,3 @@
*** Begin Patch
*** Update File: foo.txt
*** End Patch

View File

@@ -0,0 +1,6 @@
*** Begin Patch
*** Update File: missing.txt
@@
-old
+new
*** End Patch

View File

@@ -0,0 +1,7 @@
*** Begin Patch
*** Update File: old/name.txt
*** Move to: renamed/dir/name.txt
@@
-from
+new
*** End Patch

View File

@@ -0,0 +1,4 @@
*** Begin Patch
*** Add File: duplicate.txt
+new content
*** End Patch

View File

@@ -0,0 +1,3 @@
*** Begin Patch
*** Delete File: dir
*** End Patch

View File

@@ -0,0 +1,3 @@
*** Begin Patch
*** Frobnicate File: foo
*** End Patch

View File

@@ -0,0 +1,2 @@
first line
second line

View File

@@ -0,0 +1,7 @@
*** Begin Patch
*** Update File: no_newline.txt
@@
-no newline at end
+first line
+second line
*** End Patch

View File

@@ -0,0 +1,8 @@
*** Begin Patch
*** Add File: created.txt
+hello
*** Update File: missing.txt
@@
-old
+new
*** End Patch

View File

@@ -0,0 +1,4 @@
line1
line2
added line 1
added line 2

View File

@@ -0,0 +1,2 @@
line1
line2

View File

@@ -0,0 +1,6 @@
*** Begin Patch
*** Update File: input.txt
@@
+added line 1
+added line 2
*** End Patch

View File

@@ -0,0 +1,6 @@
*** Begin Patch
*** Update File: foo.txt
@@
-old
+new
*** End Patch

View File

@@ -0,0 +1,6 @@
*** Begin Patch
*** Update File: file.txt
@@
-one
+two
*** End Patch

View File

@@ -0,0 +1,18 @@
# Overview
This directory is a collection of end to end tests for the apply-patch specification, meant to be easily portable to other languages or platforms.
# Specification
Each test case is one directory, composed of input state (input/), the patch operation (patch.txt), and the expected final state (expected/). This structure is designed to keep tests simple (i.e. test exactly one patch at a time) while still providing enough flexibility to test any given operation across files.
Here's what this would look like for a simple test apply-patch test case to create a new file:
```
001_add/
input/
foo.md
expected/
foo.md
bar.md
patch.txt
```

View File

@@ -1,3 +1,4 @@
mod cli;
mod scenarios;
#[cfg(not(target_os = "windows"))]
mod tool;

View File

@@ -0,0 +1,114 @@
use assert_cmd::prelude::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use tempfile::tempdir;
#[test]
fn test_apply_patch_scenarios() -> anyhow::Result<()> {
for scenario in fs::read_dir("tests/fixtures/scenarios")? {
let scenario = scenario?;
let path = scenario.path();
if path.is_dir() {
run_apply_patch_scenario(&path)?;
}
}
Ok(())
}
/// Reads a scenario directory, copies the input files to a temporary directory, runs apply-patch,
/// and asserts that the final state matches the expected state exactly.
fn run_apply_patch_scenario(dir: &Path) -> anyhow::Result<()> {
let tmp = tempdir()?;
// Copy the input files to the temporary directory
let input_dir = dir.join("input");
if input_dir.is_dir() {
copy_dir_recursive(&input_dir, tmp.path())?;
}
// Read the patch.txt file
let patch = fs::read_to_string(dir.join("patch.txt"))?;
// Run apply_patch in the temporary directory. We intentionally do not assert
// on the exit status here; the scenarios are specified purely in terms of
// final filesystem state, which we compare below.
Command::cargo_bin("apply_patch")?
.arg(patch)
.current_dir(tmp.path())
.output()?;
// Assert that the final state matches the expected state exactly
let expected_dir = dir.join("expected");
let expected_snapshot = snapshot_dir(&expected_dir)?;
let actual_snapshot = snapshot_dir(tmp.path())?;
assert_eq!(
actual_snapshot,
expected_snapshot,
"Scenario {} did not match expected final state",
dir.display()
);
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Entry {
File(Vec<u8>),
Dir,
}
fn snapshot_dir(root: &Path) -> anyhow::Result<BTreeMap<PathBuf, Entry>> {
let mut entries = BTreeMap::new();
if root.is_dir() {
snapshot_dir_recursive(root, root, &mut entries)?;
}
Ok(entries)
}
fn snapshot_dir_recursive(
base: &Path,
dir: &Path,
entries: &mut BTreeMap<PathBuf, Entry>,
) -> anyhow::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let Some(stripped) = path.strip_prefix(base).ok() else {
continue;
};
let rel = stripped.to_path_buf();
let file_type = entry.file_type()?;
if file_type.is_dir() {
entries.insert(rel.clone(), Entry::Dir);
snapshot_dir_recursive(base, &path, entries)?;
} else if file_type.is_file() {
let contents = fs::read(&path)?;
entries.insert(rel, Entry::File(contents));
}
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
for entry in fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
let dest_path = dst.join(entry.file_name());
if file_type.is_dir() {
fs::create_dir_all(&dest_path)?;
copy_dir_recursive(&path, &dest_path)?;
} else if file_type.is_file() {
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&path, &dest_path)?;
}
}
Ok(())
}

View File

@@ -7,6 +7,7 @@ use crate::types::TurnAttemptsSiblingTurnsResponse;
use anyhow::Result;
use codex_core::auth::CodexAuth;
use codex_core::default_client::get_codex_user_agent;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
@@ -291,6 +292,7 @@ impl Client {
primary,
secondary,
credits: Self::map_credits(payload.credits),
plan_type: Some(Self::map_plan_type(payload.plan_type)),
}
}
@@ -325,6 +327,23 @@ impl Client {
})
}
fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType {
match plan_type {
crate::types::PlanType::Free => AccountPlanType::Free,
crate::types::PlanType::Plus => AccountPlanType::Plus,
crate::types::PlanType::Pro => AccountPlanType::Pro,
crate::types::PlanType::Team => AccountPlanType::Team,
crate::types::PlanType::Business => AccountPlanType::Business,
crate::types::PlanType::Enterprise => AccountPlanType::Enterprise,
crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu,
crate::types::PlanType::Guest
| crate::types::PlanType::Go
| crate::types::PlanType::FreeWorkspace
| crate::types::PlanType::Quorum
| crate::types::PlanType::K12 => AccountPlanType::Unknown,
}
}
fn window_minutes_from_seconds(seconds: i32) -> Option<i64> {
if seconds <= 0 {
return None;

View File

@@ -40,17 +40,15 @@ prefix_rule(
assert_eq!(
result,
json!({
"match": {
"decision": "forbidden",
"matchedRules": [
{
"prefixRuleMatch": {
"matchedPrefix": ["git", "push"],
"decision": "forbidden"
}
"decision": "forbidden",
"matchedRules": [
{
"prefixRuleMatch": {
"matchedPrefix": ["git", "push"],
"decision": "forbidden"
}
]
}
}
]
})
);

View File

@@ -127,6 +127,7 @@ impl Default for TaskText {
#[async_trait::async_trait]
pub trait CloudBackend: Send + Sync {
async fn list_tasks(&self, env: Option<&str>) -> Result<Vec<TaskSummary>>;
async fn get_task_summary(&self, id: TaskId) -> Result<TaskSummary>;
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>>;
/// Return assistant output messages (no diff) when available.
async fn get_task_messages(&self, id: TaskId) -> Result<Vec<String>>;

View File

@@ -63,6 +63,10 @@ impl CloudBackend for HttpClient {
self.tasks_api().list(env).await
}
async fn get_task_summary(&self, id: TaskId) -> Result<TaskSummary> {
self.tasks_api().summary(id).await
}
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>> {
self.tasks_api().diff(id).await
}
@@ -149,6 +153,75 @@ mod api {
Ok(tasks)
}
pub(crate) async fn summary(&self, id: TaskId) -> Result<TaskSummary> {
let id_str = id.0.clone();
let (details, body, ct) = self
.details_with_body(&id.0)
.await
.map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?;
let parsed: Value = serde_json::from_str(&body).map_err(|e| {
CloudTaskError::Http(format!(
"Decode error for {}: {e}; content-type={ct}; body={body}",
id.0
))
})?;
let task_obj = parsed
.get("task")
.and_then(Value::as_object)
.ok_or_else(|| {
CloudTaskError::Http(format!("Task metadata missing from details for {id_str}"))
})?;
let status_display = parsed
.get("task_status_display")
.or_else(|| task_obj.get("task_status_display"))
.and_then(Value::as_object)
.map(|m| {
m.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<HashMap<String, Value>>()
});
let status = map_status(status_display.as_ref());
let mut summary = diff_summary_from_status_display(status_display.as_ref());
if summary.files_changed == 0
&& summary.lines_added == 0
&& summary.lines_removed == 0
&& let Some(diff) = details.unified_diff()
{
summary = diff_summary_from_diff(&diff);
}
let updated_at_raw = task_obj
.get("updated_at")
.and_then(Value::as_f64)
.or_else(|| task_obj.get("created_at").and_then(Value::as_f64))
.or_else(|| latest_turn_timestamp(status_display.as_ref()));
let environment_id = task_obj
.get("environment_id")
.and_then(Value::as_str)
.map(str::to_string);
let environment_label = env_label_from_status_display(status_display.as_ref());
let attempt_total = attempt_total_from_status_display(status_display.as_ref());
let title = task_obj
.get("title")
.and_then(Value::as_str)
.unwrap_or("<untitled>")
.to_string();
let is_review = task_obj
.get("is_review")
.and_then(Value::as_bool)
.unwrap_or(false);
Ok(TaskSummary {
id,
title,
status,
updated_at: parse_updated_at(updated_at_raw.as_ref()),
environment_id,
environment_label,
summary,
is_review,
attempt_total,
})
}
pub(crate) async fn diff(&self, id: TaskId) -> Result<Option<String>> {
let (details, body, ct) = self
.details_with_body(&id.0)
@@ -679,6 +752,34 @@ mod api {
.map(str::to_string)
}
fn diff_summary_from_diff(diff: &str) -> DiffSummary {
let mut files_changed = 0usize;
let mut lines_added = 0usize;
let mut lines_removed = 0usize;
for line in diff.lines() {
if line.starts_with("diff --git ") {
files_changed += 1;
continue;
}
if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") {
continue;
}
match line.as_bytes().first() {
Some(b'+') => lines_added += 1,
Some(b'-') => lines_removed += 1,
_ => {}
}
}
if files_changed == 0 && !diff.trim().is_empty() {
files_changed = 1;
}
DiffSummary {
files_changed,
lines_added,
lines_removed,
}
}
fn diff_summary_from_status_display(v: Option<&HashMap<String, Value>>) -> DiffSummary {
let mut out = DiffSummary::default();
let Some(map) = v else { return out };
@@ -700,6 +801,17 @@ mod api {
out
}
fn latest_turn_timestamp(v: Option<&HashMap<String, Value>>) -> Option<f64> {
let map = v?;
let latest = map
.get("latest_turn_status_display")
.and_then(Value::as_object)?;
latest
.get("updated_at")
.or_else(|| latest.get("created_at"))
.and_then(Value::as_f64)
}
fn attempt_total_from_status_display(v: Option<&HashMap<String, Value>>) -> Option<usize> {
let map = v?;
let latest = map

View File

@@ -1,6 +1,7 @@
use crate::ApplyOutcome;
use crate::AttemptStatus;
use crate::CloudBackend;
use crate::CloudTaskError;
use crate::DiffSummary;
use crate::Result;
use crate::TaskId;
@@ -60,6 +61,14 @@ impl CloudBackend for MockClient {
Ok(out)
}
async fn get_task_summary(&self, id: TaskId) -> Result<TaskSummary> {
let tasks = self.list_tasks(None).await?;
tasks
.into_iter()
.find(|t| t.id == id)
.ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found (mock)", id.0)))
}
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>> {
Ok(Some(mock_diff_for(&id)))
}

View File

@@ -34,6 +34,9 @@ tokio-stream = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
unicode-width = { workspace = true }
owo-colors = { workspace = true, features = ["supports-colors"] }
supports-color = { workspace = true }
[dev-dependencies]
async-trait = { workspace = true }
pretty_assertions = { workspace = true }

View File

@@ -350,6 +350,7 @@ pub enum AppEvent {
mod tests {
use super::*;
use chrono::Utc;
use codex_cloud_tasks_client::CloudTaskError;
struct FakeBackend {
// maps env key to titles
@@ -385,6 +386,17 @@ mod tests {
Ok(out)
}
async fn get_task_summary(
&self,
id: TaskId,
) -> codex_cloud_tasks_client::Result<TaskSummary> {
self.list_tasks(None)
.await?
.into_iter()
.find(|t| t.id == id)
.ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found", id.0)))
}
async fn get_task_diff(
&self,
_id: TaskId,

View File

@@ -16,6 +16,12 @@ pub struct Cli {
pub enum Command {
/// Submit a new Codex Cloud task without launching the TUI.
Exec(ExecCommand),
/// Show the status of a Codex Cloud task.
Status(StatusCommand),
/// Apply the diff for a Codex Cloud task locally.
Apply(ApplyCommand),
/// Show the unified diff for a Codex Cloud task.
Diff(DiffCommand),
}
#[derive(Debug, Args)]
@@ -28,6 +34,10 @@ pub struct ExecCommand {
#[arg(long = "env", value_name = "ENV_ID")]
pub environment: String,
/// Git branch to run in Codex Cloud.
#[arg(long = "branch", value_name = "BRANCH", default_value = "main")]
pub branch: String,
/// Number of assistant attempts (best-of-N).
#[arg(
long = "attempts",
@@ -47,3 +57,32 @@ fn parse_attempts(input: &str) -> Result<usize, String> {
Err("attempts must be between 1 and 4".to_string())
}
}
#[derive(Debug, Args)]
pub struct StatusCommand {
/// Codex Cloud task identifier to inspect.
#[arg(value_name = "TASK_ID")]
pub task_id: String,
}
#[derive(Debug, Args)]
pub struct ApplyCommand {
/// Codex Cloud task identifier to apply.
#[arg(value_name = "TASK_ID")]
pub task_id: String,
/// Attempt number to apply (1-based).
#[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")]
pub attempt: Option<usize>,
}
#[derive(Debug, Args)]
pub struct DiffCommand {
/// Codex Cloud task identifier to display.
#[arg(value_name = "TASK_ID")]
pub task_id: String,
/// Attempt number to display (1-based).
#[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")]
pub attempt: Option<usize>,
}

View File

@@ -8,17 +8,24 @@ pub mod util;
pub use cli::Cli;
use anyhow::anyhow;
use chrono::Utc;
use codex_cloud_tasks_client::TaskStatus;
use codex_login::AuthManager;
use owo_colors::OwoColorize;
use owo_colors::Stream;
use std::cmp::Ordering;
use std::io::IsTerminal;
use std::io::Read;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use supports_color::Stream as SupportStream;
use tokio::sync::mpsc::UnboundedSender;
use tracing::info;
use tracing_subscriber::EnvFilter;
use util::append_error_log;
use util::format_relative_time;
use util::set_user_agent_suffix;
struct ApplyJob {
@@ -101,6 +108,7 @@ async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> {
let crate::cli::ExecCommand {
query,
environment,
branch,
attempts,
} = args;
let ctx = init_backend("codex_cloud_tasks_exec").await?;
@@ -110,7 +118,7 @@ async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> {
&*ctx.backend,
&env_id,
&prompt,
"main",
&branch,
false,
attempts,
)
@@ -192,6 +200,273 @@ fn resolve_query_input(query_arg: Option<String>) -> anyhow::Result<String> {
}
}
fn parse_task_id(raw: &str) -> anyhow::Result<codex_cloud_tasks_client::TaskId> {
let trimmed = raw.trim();
if trimmed.is_empty() {
anyhow::bail!("task id must not be empty");
}
let without_fragment = trimmed.split('#').next().unwrap_or(trimmed);
let without_query = without_fragment
.split('?')
.next()
.unwrap_or(without_fragment);
let id = without_query
.rsplit('/')
.next()
.unwrap_or(without_query)
.trim();
if id.is_empty() {
anyhow::bail!("task id must not be empty");
}
Ok(codex_cloud_tasks_client::TaskId(id.to_string()))
}
#[derive(Clone, Debug)]
struct AttemptDiffData {
placement: Option<i64>,
created_at: Option<chrono::DateTime<Utc>>,
diff: String,
}
fn cmp_attempt(lhs: &AttemptDiffData, rhs: &AttemptDiffData) -> Ordering {
match (lhs.placement, rhs.placement) {
(Some(a), Some(b)) => a.cmp(&b),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => match (lhs.created_at, rhs.created_at) {
(Some(a), Some(b)) => a.cmp(&b),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
},
}
}
async fn collect_attempt_diffs(
backend: &dyn codex_cloud_tasks_client::CloudBackend,
task_id: &codex_cloud_tasks_client::TaskId,
) -> anyhow::Result<Vec<AttemptDiffData>> {
let text =
codex_cloud_tasks_client::CloudBackend::get_task_text(backend, task_id.clone()).await?;
let mut attempts = Vec::new();
if let Some(diff) =
codex_cloud_tasks_client::CloudBackend::get_task_diff(backend, task_id.clone()).await?
{
attempts.push(AttemptDiffData {
placement: text.attempt_placement,
created_at: None,
diff,
});
}
if let Some(turn_id) = text.turn_id {
let siblings = codex_cloud_tasks_client::CloudBackend::list_sibling_attempts(
backend,
task_id.clone(),
turn_id,
)
.await?;
for sibling in siblings {
if let Some(diff) = sibling.diff {
attempts.push(AttemptDiffData {
placement: sibling.attempt_placement,
created_at: sibling.created_at,
diff,
});
}
}
}
attempts.sort_by(cmp_attempt);
if attempts.is_empty() {
anyhow::bail!(
"No diff available for task {}; it may still be running.",
task_id.0
);
}
Ok(attempts)
}
fn select_attempt(
attempts: &[AttemptDiffData],
attempt: Option<usize>,
) -> anyhow::Result<&AttemptDiffData> {
if attempts.is_empty() {
anyhow::bail!("No attempts available");
}
let desired = attempt.unwrap_or(1);
let idx = desired
.checked_sub(1)
.ok_or_else(|| anyhow!("attempt must be at least 1"))?;
if idx >= attempts.len() {
anyhow::bail!(
"Attempt {desired} not available; only {} attempt(s) found",
attempts.len()
);
}
Ok(&attempts[idx])
}
fn task_status_label(status: &TaskStatus) -> &'static str {
match status {
TaskStatus::Pending => "PENDING",
TaskStatus::Ready => "READY",
TaskStatus::Applied => "APPLIED",
TaskStatus::Error => "ERROR",
}
}
fn summary_line(summary: &codex_cloud_tasks_client::DiffSummary, colorize: bool) -> String {
if summary.files_changed == 0 && summary.lines_added == 0 && summary.lines_removed == 0 {
let base = "no diff";
return if colorize {
base.if_supports_color(Stream::Stdout, |t| t.dimmed())
.to_string()
} else {
base.to_string()
};
}
let adds = summary.lines_added;
let dels = summary.lines_removed;
let files = summary.files_changed;
if colorize {
let adds_raw = format!("+{adds}");
let adds_str = adds_raw
.as_str()
.if_supports_color(Stream::Stdout, |t| t.green())
.to_string();
let dels_raw = format!("-{dels}");
let dels_str = dels_raw
.as_str()
.if_supports_color(Stream::Stdout, |t| t.red())
.to_string();
let bullet = ""
.if_supports_color(Stream::Stdout, |t| t.dimmed())
.to_string();
let file_label = "file"
.if_supports_color(Stream::Stdout, |t| t.dimmed())
.to_string();
let plural = if files == 1 { "" } else { "s" };
format!("{adds_str}/{dels_str} {bullet} {files} {file_label}{plural}")
} else {
format!(
"+{adds}/-{dels}{files} file{}",
if files == 1 { "" } else { "s" }
)
}
}
fn format_task_status_lines(
task: &codex_cloud_tasks_client::TaskSummary,
now: chrono::DateTime<Utc>,
colorize: bool,
) -> Vec<String> {
let mut lines = Vec::new();
let status = task_status_label(&task.status);
let status = if colorize {
match task.status {
TaskStatus::Ready => status
.if_supports_color(Stream::Stdout, |t| t.green())
.to_string(),
TaskStatus::Pending => status
.if_supports_color(Stream::Stdout, |t| t.magenta())
.to_string(),
TaskStatus::Applied => status
.if_supports_color(Stream::Stdout, |t| t.blue())
.to_string(),
TaskStatus::Error => status
.if_supports_color(Stream::Stdout, |t| t.red())
.to_string(),
}
} else {
status.to_string()
};
lines.push(format!("[{status}] {}", task.title));
let mut meta_parts = Vec::new();
if let Some(label) = task.environment_label.as_deref().filter(|s| !s.is_empty()) {
if colorize {
meta_parts.push(
label
.if_supports_color(Stream::Stdout, |t| t.dimmed())
.to_string(),
);
} else {
meta_parts.push(label.to_string());
}
} else if let Some(id) = task.environment_id.as_deref() {
if colorize {
meta_parts.push(
id.if_supports_color(Stream::Stdout, |t| t.dimmed())
.to_string(),
);
} else {
meta_parts.push(id.to_string());
}
}
let when = format_relative_time(now, task.updated_at);
meta_parts.push(if colorize {
when.as_str()
.if_supports_color(Stream::Stdout, |t| t.dimmed())
.to_string()
} else {
when
});
let sep = if colorize {
""
.if_supports_color(Stream::Stdout, |t| t.dimmed())
.to_string()
} else {
"".to_string()
};
lines.push(meta_parts.join(&sep));
lines.push(summary_line(&task.summary, colorize));
lines
}
async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_status").await?;
let task_id = parse_task_id(&args.task_id)?;
let summary =
codex_cloud_tasks_client::CloudBackend::get_task_summary(&*ctx.backend, task_id).await?;
let now = Utc::now();
let colorize = supports_color::on(SupportStream::Stdout).is_some();
for line in format_task_status_lines(&summary, now, colorize) {
println!("{line}");
}
if !matches!(summary.status, TaskStatus::Ready) {
std::process::exit(1);
}
Ok(())
}
async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_diff").await?;
let task_id = parse_task_id(&args.task_id)?;
let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?;
let selected = select_attempt(&attempts, args.attempt)?;
print!("{}", selected.diff);
Ok(())
}
async fn run_apply_command(args: crate::cli::ApplyCommand) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_apply").await?;
let task_id = parse_task_id(&args.task_id)?;
let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?;
let selected = select_attempt(&attempts, args.attempt)?;
let outcome = codex_cloud_tasks_client::CloudBackend::apply_task(
&*ctx.backend,
task_id,
Some(selected.diff.clone()),
)
.await?;
println!("{}", outcome.message);
if !matches!(
outcome.status,
codex_cloud_tasks_client::ApplyStatus::Success
) {
std::process::exit(1);
}
Ok(())
}
fn level_from_status(status: codex_cloud_tasks_client::ApplyStatus) -> app::ApplyResultLevel {
match status {
codex_cloud_tasks_client::ApplyStatus::Success => app::ApplyResultLevel::Success,
@@ -321,6 +596,9 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
if let Some(command) = cli.command {
return match command {
crate::cli::Command::Exec(args) => run_exec_command(args).await,
crate::cli::Command::Status(args) => run_status_command(args).await,
crate::cli::Command::Apply(args) => run_apply_command(args).await,
crate::cli::Command::Diff(args) => run_diff_command(args).await,
};
}
let Cli { .. } = cli;
@@ -1712,14 +1990,111 @@ fn pretty_lines_from_error(raw: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use codex_cloud_tasks_client::DiffSummary;
use codex_cloud_tasks_client::MockClient;
use codex_cloud_tasks_client::TaskId;
use codex_cloud_tasks_client::TaskStatus;
use codex_cloud_tasks_client::TaskSummary;
use codex_tui::ComposerAction;
use codex_tui::ComposerInput;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
#[test]
fn format_task_status_lines_with_diff_and_label() {
let now = Utc::now();
let task = TaskSummary {
id: TaskId("task_1".to_string()),
title: "Example task".to_string(),
status: TaskStatus::Ready,
updated_at: now,
environment_id: Some("env-1".to_string()),
environment_label: Some("Env".to_string()),
summary: DiffSummary {
files_changed: 3,
lines_added: 5,
lines_removed: 2,
},
is_review: false,
attempt_total: None,
};
let lines = format_task_status_lines(&task, now, false);
assert_eq!(
lines,
vec![
"[READY] Example task".to_string(),
"Env • 0s ago".to_string(),
"+5/-2 • 3 files".to_string(),
]
);
}
#[test]
fn format_task_status_lines_without_diff_falls_back() {
let now = Utc::now();
let task = TaskSummary {
id: TaskId("task_2".to_string()),
title: "No diff task".to_string(),
status: TaskStatus::Pending,
updated_at: now,
environment_id: Some("env-2".to_string()),
environment_label: None,
summary: DiffSummary::default(),
is_review: false,
attempt_total: Some(1),
};
let lines = format_task_status_lines(&task, now, false);
assert_eq!(
lines,
vec![
"[PENDING] No diff task".to_string(),
"env-2 • 0s ago".to_string(),
"no diff".to_string(),
]
);
}
#[tokio::test]
async fn collect_attempt_diffs_includes_sibling_attempts() {
let backend = MockClient;
let task_id = parse_task_id("https://chatgpt.com/codex/tasks/T-1000").expect("id");
let attempts = collect_attempt_diffs(&backend, &task_id)
.await
.expect("attempts");
assert_eq!(attempts.len(), 2);
assert_eq!(attempts[0].placement, Some(0));
assert_eq!(attempts[1].placement, Some(1));
assert!(!attempts[0].diff.is_empty());
assert!(!attempts[1].diff.is_empty());
}
#[test]
fn select_attempt_validates_bounds() {
let attempts = vec![AttemptDiffData {
placement: Some(0),
created_at: None,
diff: "diff --git a/file b/file\n".to_string(),
}];
let first = select_attempt(&attempts, Some(1)).expect("attempt 1");
assert_eq!(first.diff, "diff --git a/file b/file\n");
assert!(select_attempt(&attempts, Some(2)).is_err());
}
#[test]
fn parse_task_id_from_url_and_raw() {
let raw = parse_task_id("task_i_abc123").expect("raw id");
assert_eq!(raw.0, "task_i_abc123");
let url =
parse_task_id("https://chatgpt.com/codex/tasks/task_i_123456?foo=bar").expect("url id");
assert_eq!(url.0, "task_i_123456");
assert!(parse_task_id(" ").is_err());
}
#[test]
#[ignore = "very slow"]
fn composer_input_renders_typed_characters() {

View File

@@ -20,8 +20,7 @@ use std::time::Instant;
use crate::app::App;
use crate::app::AttemptView;
use chrono::Local;
use chrono::Utc;
use crate::util::format_relative_time_now;
use codex_cloud_tasks_client::AttemptStatus;
use codex_cloud_tasks_client::TaskStatus;
use codex_tui::render_markdown_text;
@@ -804,7 +803,7 @@ fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> Li
if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) {
meta.push(lbl.clone().dim());
}
let when = format_relative_time(t.updated_at).dim();
let when = format_relative_time_now(t.updated_at).dim();
if !meta.is_empty() {
meta.push(" ".into());
meta.push("".dim());
@@ -841,27 +840,6 @@ fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> Li
ListItem::new(vec![title, meta_line, sub, spacer])
}
fn format_relative_time(ts: chrono::DateTime<Utc>) -> String {
let now = Utc::now();
let mut secs = (now - ts).num_seconds();
if secs < 0 {
secs = 0;
}
if secs < 60 {
return format!("{secs}s ago");
}
let mins = secs / 60;
if mins < 60 {
return format!("{mins}m ago");
}
let hours = mins / 60;
if hours < 24 {
return format!("{hours}h ago");
}
let local = ts.with_timezone(&Local);
local.format("%b %e %H:%M").to_string()
}
fn draw_inline_spinner(
frame: &mut Frame,
area: Rect,

View File

@@ -1,4 +1,6 @@
use base64::Engine as _;
use chrono::DateTime;
use chrono::Local;
use chrono::Utc;
use reqwest::header::HeaderMap;
@@ -120,3 +122,27 @@ pub fn task_url(base_url: &str, task_id: &str) -> String {
}
format!("{normalized}/codex/tasks/{task_id}")
}
pub fn format_relative_time(reference: DateTime<Utc>, ts: DateTime<Utc>) -> String {
let mut secs = (reference - ts).num_seconds();
if secs < 0 {
secs = 0;
}
if secs < 60 {
return format!("{secs}s ago");
}
let mins = secs / 60;
if mins < 60 {
return format!("{mins}m ago");
}
let hours = mins / 60;
if hours < 24 {
return format!("{hours}h ago");
}
let local = ts.with_timezone(&Local);
local.format("%b %e %H:%M").to_string()
}
pub fn format_relative_time_now(ts: DateTime<Utc>) -> String {
format_relative_time(Utc::now(), ts)
}

View File

@@ -25,6 +25,8 @@ anyhow = { workspace = true }
assert_matches = { workspace = true }
pretty_assertions = { workspace = true }
tokio-test = { workspace = true }
wiremock = { workspace = true }
reqwest = { workspace = true }
[lints]
workspace = true

View File

@@ -1,8 +1,8 @@
use crate::error::ApiError;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::Verbosity as VerbosityConfig;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::TokenUsage;
use futures::Stream;

View File

@@ -1,4 +1,5 @@
pub mod chat;
pub mod compact;
pub mod models;
pub mod responses;
mod streaming;

View File

@@ -0,0 +1,217 @@
use crate::auth::AuthProvider;
use crate::auth::add_auth_headers;
use crate::error::ApiError;
use crate::provider::Provider;
use crate::telemetry::run_with_request_telemetry;
use codex_client::HttpTransport;
use codex_client::RequestTelemetry;
use codex_protocol::openai_models::ModelsResponse;
use http::HeaderMap;
use http::Method;
use std::sync::Arc;
pub struct ModelsClient<T: HttpTransport, A: AuthProvider> {
transport: T,
provider: Provider,
auth: A,
request_telemetry: Option<Arc<dyn RequestTelemetry>>,
}
impl<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
Self {
transport,
provider,
auth,
request_telemetry: None,
}
}
pub fn with_telemetry(mut self, request: Option<Arc<dyn RequestTelemetry>>) -> Self {
self.request_telemetry = request;
self
}
fn path(&self) -> &'static str {
"models"
}
pub async fn list_models(
&self,
client_version: &str,
extra_headers: HeaderMap,
) -> Result<ModelsResponse, ApiError> {
let builder = || {
let mut req = self.provider.build_request(Method::GET, self.path());
req.headers.extend(extra_headers.clone());
let separator = if req.url.contains('?') { '&' } else { '?' };
req.url = format!("{}{}client_version={client_version}", req.url, separator);
add_auth_headers(&self.auth, req)
};
let resp = run_with_request_telemetry(
self.provider.retry.to_policy(),
self.request_telemetry.clone(),
builder,
|req| self.transport.execute(req),
)
.await?;
serde_json::from_slice::<ModelsResponse>(&resp.body).map_err(|e| {
ApiError::Stream(format!(
"failed to decode models response: {e}; body: {}",
String::from_utf8_lossy(&resp.body)
))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::RetryConfig;
use crate::provider::WireApi;
use async_trait::async_trait;
use codex_client::Request;
use codex_client::Response;
use codex_client::StreamResponse;
use codex_client::TransportError;
use http::HeaderMap;
use http::StatusCode;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
#[derive(Clone, Default)]
struct CapturingTransport {
last_request: Arc<Mutex<Option<Request>>>,
body: Arc<ModelsResponse>,
}
#[async_trait]
impl HttpTransport for CapturingTransport {
async fn execute(&self, req: Request) -> Result<Response, TransportError> {
*self.last_request.lock().unwrap() = Some(req);
let body = serde_json::to_vec(&*self.body).unwrap();
Ok(Response {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: body.into(),
})
}
async fn stream(&self, _req: Request) -> Result<StreamResponse, TransportError> {
Err(TransportError::Build("stream should not run".to_string()))
}
}
#[derive(Clone, Default)]
struct DummyAuth;
impl AuthProvider for DummyAuth {
fn bearer_token(&self) -> Option<String> {
None
}
}
fn provider(base_url: &str) -> Provider {
Provider {
name: "test".to_string(),
base_url: base_url.to_string(),
query_params: None,
wire: WireApi::Responses,
headers: HeaderMap::new(),
retry: RetryConfig {
max_attempts: 1,
base_delay: Duration::from_millis(1),
retry_429: false,
retry_5xx: true,
retry_transport: true,
},
stream_idle_timeout: Duration::from_secs(1),
}
}
#[tokio::test]
async fn appends_client_version_query() {
let response = ModelsResponse { models: Vec::new() };
let transport = CapturingTransport {
last_request: Arc::new(Mutex::new(None)),
body: Arc::new(response),
};
let client = ModelsClient::new(
transport.clone(),
provider("https://example.com/api/codex"),
DummyAuth,
);
let result = client
.list_models("0.99.0", HeaderMap::new())
.await
.expect("request should succeed");
assert_eq!(result.models.len(), 0);
let url = transport
.last_request
.lock()
.unwrap()
.as_ref()
.unwrap()
.url
.clone();
assert_eq!(
url,
"https://example.com/api/codex/models?client_version=0.99.0"
);
}
#[tokio::test]
async fn parses_models_response() {
let response = ModelsResponse {
models: vec![
serde_json::from_value(json!({
"slug": "gpt-test",
"display_name": "gpt-test",
"description": "desc",
"default_reasoning_level": "medium",
"supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}, {"effort": "high", "description": "high"}],
"shell_type": "shell_command",
"visibility": "list",
"minimal_client_version": [0, 99, 0],
"supported_in_api": true,
"priority": 1,
"upgrade": null,
}))
.unwrap(),
],
};
let transport = CapturingTransport {
last_request: Arc::new(Mutex::new(None)),
body: Arc::new(response),
};
let client = ModelsClient::new(
transport,
provider("https://example.com/api/codex"),
DummyAuth,
);
let result = client
.list_models("0.99.0", HeaderMap::new())
.await
.expect("request should succeed");
assert_eq!(result.models.len(), 1);
assert_eq!(result.models[0].slug, "gpt-test");
assert_eq!(result.models[0].supported_in_api, true);
assert_eq!(result.models[0].priority, 1);
}
}

View File

@@ -22,6 +22,7 @@ pub use crate::common::create_text_param_for_request;
pub use crate::endpoint::chat::AggregateStreamExt;
pub use crate::endpoint::chat::ChatClient;
pub use crate::endpoint::compact::CompactClient;
pub use crate::endpoint::models::ModelsClient;
pub use crate::endpoint::responses::ResponsesClient;
pub use crate::endpoint::responses::ResponsesOptions;
pub use crate::error::ApiError;

View File

@@ -37,6 +37,7 @@ pub fn parse_rate_limit(headers: &HeaderMap) -> Option<RateLimitSnapshot> {
primary,
secondary,
credits,
plan_type: None,
})
}

View File

@@ -10,6 +10,7 @@ use eventsource_stream::Eventsource;
use futures::Stream;
use futures::StreamExt;
use std::collections::HashMap;
use std::collections::HashSet;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::Instant;
@@ -41,12 +42,17 @@ pub async fn process_chat_sse<S>(
#[derive(Default, Debug)]
struct ToolCallState {
id: Option<String>,
name: Option<String>,
arguments: String,
}
let mut tool_calls: HashMap<String, ToolCallState> = HashMap::new();
let mut tool_call_order: Vec<String> = Vec::new();
let mut tool_calls: HashMap<usize, ToolCallState> = HashMap::new();
let mut tool_call_order: Vec<usize> = Vec::new();
let mut tool_call_order_seen: HashSet<usize> = HashSet::new();
let mut tool_call_index_by_id: HashMap<String, usize> = HashMap::new();
let mut next_tool_call_index = 0usize;
let mut last_tool_call_index: Option<usize> = None;
let mut assistant_item: Option<ResponseItem> = None;
let mut reasoning_item: Option<ResponseItem> = None;
let mut completed_sent = false;
@@ -149,26 +155,55 @@ pub async fn process_chat_sse<S>(
if let Some(tool_call_values) = delta.get("tool_calls").and_then(|c| c.as_array()) {
for tool_call in tool_call_values {
let id = tool_call
.get("id")
.and_then(|i| i.as_str())
.map(str::to_string)
.unwrap_or_else(|| format!("tool-call-{}", tool_call_order.len()));
let mut index = tool_call
.get("index")
.and_then(serde_json::Value::as_u64)
.map(|i| i as usize);
let call_state = tool_calls.entry(id.clone()).or_default();
if !tool_call_order.contains(&id) {
tool_call_order.push(id.clone());
let mut call_id_for_lookup = None;
if let Some(call_id) = tool_call.get("id").and_then(|i| i.as_str()) {
call_id_for_lookup = Some(call_id.to_string());
if let Some(existing) = tool_call_index_by_id.get(call_id) {
index = Some(*existing);
}
}
if index.is_none() && call_id_for_lookup.is_none() {
index = last_tool_call_index;
}
let index = index.unwrap_or_else(|| {
while tool_calls.contains_key(&next_tool_call_index) {
next_tool_call_index += 1;
}
let idx = next_tool_call_index;
next_tool_call_index += 1;
idx
});
let call_state = tool_calls.entry(index).or_default();
if tool_call_order_seen.insert(index) {
tool_call_order.push(index);
}
if let Some(id) = tool_call.get("id").and_then(|i| i.as_str()) {
call_state.id.get_or_insert_with(|| id.to_string());
tool_call_index_by_id.entry(id.to_string()).or_insert(index);
}
if let Some(func) = tool_call.get("function") {
if let Some(fname) = func.get("name").and_then(|n| n.as_str()) {
call_state.name = Some(fname.to_string());
if let Some(fname) = func.get("name").and_then(|n| n.as_str())
&& !fname.is_empty()
{
call_state.name.get_or_insert_with(|| fname.to_string());
}
if let Some(arguments) = func.get("arguments").and_then(|a| a.as_str())
{
call_state.arguments.push_str(arguments);
}
}
last_tool_call_index = Some(index);
}
}
}
@@ -222,13 +257,25 @@ pub async fn process_chat_sse<S>(
.await;
}
for call_id in tool_call_order.drain(..) {
let state = tool_calls.remove(&call_id).unwrap_or_default();
for index in tool_call_order.drain(..) {
let Some(state) = tool_calls.remove(&index) else {
continue;
};
tool_call_order_seen.remove(&index);
let ToolCallState {
id,
name,
arguments,
} = state;
let Some(name) = name else {
debug!("Skipping tool call at index {index} because name is missing");
continue;
};
let item = ResponseItem::FunctionCall {
id: None,
name: state.name.unwrap_or_default(),
arguments: state.arguments,
call_id: call_id.clone(),
name,
arguments,
call_id: id.unwrap_or_else(|| format!("tool-call-{index}")),
};
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
}
@@ -333,6 +380,59 @@ mod tests {
out
}
#[tokio::test]
async fn concatenates_tool_call_arguments_across_deltas() {
let delta_name = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_a",
"index": 0,
"function": { "name": "do_a" }
}]
}
}]
});
let delta_args_1 = json!({
"choices": [{
"delta": {
"tool_calls": [{
"index": 0,
"function": { "arguments": "{ \"foo\":" }
}]
}
}]
});
let delta_args_2 = json!({
"choices": [{
"delta": {
"tool_calls": [{
"index": 0,
"function": { "arguments": "1}" }
}]
}
}]
});
let finish = json!({
"choices": [{
"finish_reason": "tool_calls"
}]
});
let body = build_body(&[delta_name, delta_args_1, delta_args_2, finish]);
let events = collect_events(&body).await;
assert_matches!(
&events[..],
[
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }),
ResponseEvent::Completed { .. }
] if call_id == "call_a" && name == "do_a" && arguments == "{ \"foo\":1}"
);
}
#[tokio::test]
async fn emits_multiple_tool_calls() {
let delta_a = json!({
@@ -365,50 +465,74 @@ mod tests {
let body = build_body(&[delta_a, delta_b, finish]);
let events = collect_events(&body).await;
assert_eq!(events.len(), 3);
assert_matches!(
&events[0],
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. })
if call_id == "call_a" && name == "do_a" && arguments == "{\"foo\":1}"
&events[..],
[
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }),
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }),
ResponseEvent::Completed { .. }
] if call_a == "call_a" && name_a == "do_a" && args_a == "{\"foo\":1}" && call_b == "call_b" && name_b == "do_b" && args_b == "{\"bar\":2}"
);
assert_matches!(
&events[1],
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. })
if call_id == "call_b" && name == "do_b" && arguments == "{\"bar\":2}"
);
assert_matches!(events[2], ResponseEvent::Completed { .. });
}
#[tokio::test]
async fn concatenates_tool_call_arguments_across_deltas() {
let delta_name = json!({
async fn emits_tool_calls_for_multiple_choices() {
let payload = json!({
"choices": [
{
"delta": {
"tool_calls": [{
"id": "call_a",
"index": 0,
"function": { "name": "do_a", "arguments": "{}" }
}]
},
"finish_reason": "tool_calls"
},
{
"delta": {
"tool_calls": [{
"id": "call_b",
"index": 0,
"function": { "name": "do_b", "arguments": "{}" }
}]
},
"finish_reason": "tool_calls"
}
]
});
let body = build_body(&[payload]);
let events = collect_events(&body).await;
assert_matches!(
&events[..],
[
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }),
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }),
ResponseEvent::Completed { .. }
] if call_a == "call_a" && name_a == "do_a" && args_a == "{}" && call_b == "call_b" && name_b == "do_b" && args_b == "{}"
);
}
#[tokio::test]
async fn merges_tool_calls_by_index_when_id_missing_on_subsequent_deltas() {
let delta_with_id = json!({
"choices": [{
"delta": {
"tool_calls": [{
"index": 0,
"id": "call_a",
"function": { "name": "do_a" }
"function": { "name": "do_a", "arguments": "{ \"foo\":" }
}]
}
}]
});
let delta_args_1 = json!({
let delta_without_id = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_a",
"function": { "arguments": "{ \"foo\":" }
}]
}
}]
});
let delta_args_2 = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_a",
"index": 0,
"function": { "arguments": "1}" }
}]
}
@@ -421,7 +545,7 @@ mod tests {
}]
});
let body = build_body(&[delta_name, delta_args_1, delta_args_2, finish]);
let body = build_body(&[delta_with_id, delta_without_id, finish]);
let events = collect_events(&body).await;
assert_matches!(
&events[..],
@@ -432,6 +556,47 @@ mod tests {
);
}
#[tokio::test]
async fn preserves_tool_call_name_when_empty_deltas_arrive() {
let delta_with_name = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_a",
"function": { "name": "do_a" }
}]
}
}]
});
let delta_with_empty_name = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_a",
"function": { "name": "", "arguments": "{}" }
}]
}
}]
});
let finish = json!({
"choices": [{
"finish_reason": "tool_calls"
}]
});
let body = build_body(&[delta_with_name, delta_with_empty_name, finish]);
let events = collect_events(&body).await;
assert_matches!(
&events[..],
[
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { name, arguments, .. }),
ResponseEvent::Completed { .. }
] if name == "do_a" && arguments == "{}"
);
}
#[tokio::test]
async fn emits_tool_calls_even_when_content_and_reasoning_present() {
let delta_content_and_tools = json!({

View File

@@ -0,0 +1,111 @@
use codex_api::AuthProvider;
use codex_api::ModelsClient;
use codex_api::provider::Provider;
use codex_api::provider::RetryConfig;
use codex_api::provider::WireApi;
use codex_client::ReqwestTransport;
use codex_protocol::openai_models::ClientVersion;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use http::HeaderMap;
use http::Method;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[derive(Clone, Default)]
struct DummyAuth;
impl AuthProvider for DummyAuth {
fn bearer_token(&self) -> Option<String> {
None
}
}
fn provider(base_url: &str) -> Provider {
Provider {
name: "test".to_string(),
base_url: base_url.to_string(),
query_params: None,
wire: WireApi::Responses,
headers: HeaderMap::new(),
retry: RetryConfig {
max_attempts: 1,
base_delay: std::time::Duration::from_millis(1),
retry_429: false,
retry_5xx: true,
retry_transport: true,
},
stream_idle_timeout: std::time::Duration::from_secs(1),
}
}
#[tokio::test]
async fn models_client_hits_models_endpoint() {
let server = MockServer::start().await;
let base_url = format!("{}/api/codex", server.uri());
let response = ModelsResponse {
models: vec![ModelInfo {
slug: "gpt-test".to_string(),
display_name: "gpt-test".to_string(),
description: Some("desc".to_string()),
default_reasoning_level: ReasoningEffort::Medium,
supported_reasoning_levels: vec![
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description: ReasoningEffort::Low.to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: ReasoningEffort::Medium.to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: ReasoningEffort::High.to_string(),
},
],
shell_type: ConfigShellToolType::ShellCommand,
visibility: ModelVisibility::List,
minimal_client_version: ClientVersion(0, 1, 0),
supported_in_api: true,
priority: 1,
upgrade: None,
}],
};
Mock::given(method("GET"))
.and(path("/api/codex/models"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_json(&response),
)
.mount(&server)
.await;
let transport = ReqwestTransport::new(reqwest::Client::new());
let client = ModelsClient::new(transport, provider(&base_url), DummyAuth);
let result = client
.list_models("0.1.0", HeaderMap::new())
.await
.expect("models request should succeed");
assert_eq!(result.models.len(), 1);
assert_eq!(result.models[0].slug, "gpt-test");
let received = server
.received_requests()
.await
.expect("should capture requests");
assert_eq!(received.len(), 1);
assert_eq!(received[0].method, Method::GET.as_str());
assert_eq!(received[0].url.path(), "/api/codex/models");
}

View File

@@ -9,12 +9,10 @@ workspace = true
[dependencies]
clap = { workspace = true, features = ["derive", "wrap_help"], optional = true }
codex-app-server-protocol = { workspace = true }
codex-core = { workspace = true }
codex-lmstudio = { workspace = true }
codex-ollama = { workspace = true }
codex-protocol = { workspace = true }
once_cell = { workspace = true }
serde = { workspace = true, optional = true }
toml = { workspace = true, optional = true }

View File

@@ -12,15 +12,14 @@ pub fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, Stri
("approval", config.approval_policy.to_string()),
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
];
if config.model_provider.wire_api == WireApi::Responses
&& config.model_family.supports_reasoning_summaries
{
if config.model_provider.wire_api == WireApi::Responses {
let reasoning_effort = config
.model_reasoning_effort
.or(config.model_family.default_reasoning_effort)
.map(|effort| effort.to_string())
.unwrap_or_else(|| "none".to_string());
entries.push(("reasoning effort", reasoning_effort));
.map(|effort| effort.to_string());
entries.push((
"reasoning effort",
reasoning_effort.unwrap_or_else(|| "none".to_string()),
));
entries.push((
"reasoning summaries",
config.model_reasoning_summary.to_string(),

View File

@@ -32,8 +32,6 @@ mod config_summary;
pub use config_summary::create_config_summary_entries;
// Shared fuzzy matcher (used by TUI selection popups and other UI filtering)
pub mod fuzzy_match;
// Shared model presets used by TUI and MCP server
pub mod model_presets;
// Shared approval presets (AskForApproval + Sandbox) used by TUI and MCP server
// Not to be confused with AskForApproval, which we should probably rename to EscalationPolicy.
pub mod approval_presets;

View File

@@ -90,6 +90,7 @@ wildmatch = { workspace = true }
[features]
deterministic_process_ids = []
test-support = []
[target.'cfg(target_os = "linux")'.dependencies]

View File

@@ -70,7 +70,9 @@ pub(crate) async fn apply_patch(
)
.await;
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {
InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
action,
user_explicitly_approved_this_action: true,

View File

@@ -227,23 +227,6 @@ impl CodexAuth {
})
}
/// Raw plan string from the ID token (including unknown/new plan types).
pub fn raw_plan_type(&self) -> Option<String> {
self.get_plan_type().map(|plan| match plan {
InternalPlanType::Known(k) => format!("{k:?}"),
InternalPlanType::Unknown(raw) => raw,
})
}
/// Raw internal plan value from the ID token.
/// Exposes the underlying `token_data::PlanType` without mapping it to the
/// public `AccountPlanType`. Use this when downstream code needs to inspect
/// internal/unknown plan strings exactly as issued in the token.
pub(crate) fn get_plan_type(&self) -> Option<InternalPlanType> {
self.get_current_token_data()
.and_then(|t| t.id_token.chatgpt_plan_type)
}
fn get_current_auth_json(&self) -> Option<AuthDotJson> {
#[expect(clippy::unwrap_used)]
self.auth_dot_json.lock().unwrap().clone()
@@ -1041,10 +1024,6 @@ mod tests {
.expect("auth available");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro));
pretty_assertions::assert_eq!(
auth.get_plan_type(),
Some(InternalPlanType::Known(InternalKnownPlan::Pro))
);
}
#[test]
@@ -1065,10 +1044,6 @@ mod tests {
.expect("auth available");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown));
pretty_assertions::assert_eq!(
auth.get_plan_type(),
Some(InternalPlanType::Unknown("mystery-tier".to_string()))
);
}
}
@@ -1201,4 +1176,8 @@ impl AuthManager {
self.reload();
Ok(removed)
}
pub fn get_auth_mode(&self) -> Option<AuthMode> {
self.auth().map(|a| a.mode)
}
}

View File

@@ -20,9 +20,9 @@ use codex_api::error::ApiError;
use codex_app_server_protocol::AuthMode;
use codex_otel::otel_event_manager::OtelEventManager;
use codex_protocol::ConversationId;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::SessionSource;
use eventsource_stream::Event;
use eventsource_stream::EventStreamError;
@@ -46,10 +46,10 @@ use crate::default_client::build_reqwest_client;
use crate::error::CodexErr;
use crate::error::Result;
use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::model_family::ModelFamily;
use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::WireApi;
use crate::openai_model_info::get_model_info;
use crate::openai_models::model_family::ModelFamily;
use crate::tools::spec::create_tools_json_for_chat_completions_api;
use crate::tools::spec::create_tools_json_for_responses_api;
@@ -57,6 +57,7 @@ use crate::tools::spec::create_tools_json_for_responses_api;
pub struct ModelClient {
config: Arc<Config>,
auth_manager: Option<Arc<AuthManager>>,
model_family: ModelFamily,
otel_event_manager: OtelEventManager,
provider: ModelProviderInfo,
conversation_id: ConversationId,
@@ -70,6 +71,7 @@ impl ModelClient {
pub fn new(
config: Arc<Config>,
auth_manager: Option<Arc<AuthManager>>,
model_family: ModelFamily,
otel_event_manager: OtelEventManager,
provider: ModelProviderInfo,
effort: Option<ReasoningEffortConfig>,
@@ -80,6 +82,7 @@ impl ModelClient {
Self {
config,
auth_manager,
model_family,
otel_event_manager,
provider,
conversation_id,
@@ -90,16 +93,18 @@ impl ModelClient {
}
pub fn get_model_context_window(&self) -> Option<i64> {
let pct = self.config.model_family.effective_context_window_percent;
let model_family = self.get_model_family();
let effective_context_window_percent = model_family.effective_context_window_percent;
self.config
.model_context_window
.or_else(|| get_model_info(&self.config.model_family).map(|info| info.context_window))
.map(|w| w.saturating_mul(pct) / 100)
.or_else(|| get_model_info(&model_family).map(|info| info.context_window))
.map(|w| w.saturating_mul(effective_context_window_percent) / 100)
}
pub fn get_auto_compact_token_limit(&self) -> Option<i64> {
let model_family = self.get_model_family();
self.config.model_auto_compact_token_limit.or_else(|| {
get_model_info(&self.config.model_family).and_then(|info| info.auto_compact_token_limit)
get_model_info(&model_family).and_then(|info| info.auto_compact_token_limit)
})
}
@@ -149,9 +154,8 @@ impl ModelClient {
}
let auth_manager = self.auth_manager.clone();
let instructions = prompt
.get_full_instructions(&self.config.model_family)
.into_owned();
let model_family = self.get_model_family();
let instructions = prompt.get_full_instructions(&model_family).into_owned();
let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?;
let api_prompt = build_api_prompt(prompt, instructions, tools_json);
let conversation_id = self.conversation_id.to_string();
@@ -204,16 +208,13 @@ impl ModelClient {
}
let auth_manager = self.auth_manager.clone();
let instructions = prompt
.get_full_instructions(&self.config.model_family)
.into_owned();
let model_family = self.get_model_family();
let instructions = prompt.get_full_instructions(&model_family).into_owned();
let tools_json: Vec<Value> = create_tools_json_for_responses_api(&prompt.tools)?;
let reasoning = if self.config.model_family.supports_reasoning_summaries {
let reasoning = if model_family.supports_reasoning_summaries {
Some(Reasoning {
effort: self
.effort
.or(self.config.model_family.default_reasoning_effort),
effort: self.effort.or(model_family.default_reasoning_effort),
summary: Some(self.summary),
})
} else {
@@ -226,15 +227,15 @@ impl ModelClient {
vec![]
};
let verbosity = if self.config.model_family.support_verbosity {
let verbosity = if model_family.support_verbosity {
self.config
.model_verbosity
.or(self.config.model_family.default_verbosity)
.or(model_family.default_verbosity)
} else {
if self.config.model_verbosity.is_some() {
warn!(
"model_verbosity is set but ignored as the model does not support verbosity: {}",
self.config.model_family.family
model_family.family
);
}
None
@@ -305,7 +306,7 @@ impl ModelClient {
/// Returns the currently configured model family.
pub fn get_model_family(&self) -> ModelFamily {
self.config.model_family.clone()
self.model_family.clone()
}
/// Returns the current reasoning effort setting.
@@ -342,7 +343,7 @@ impl ModelClient {
.with_telemetry(Some(request_telemetry));
let instructions = prompt
.get_full_instructions(&self.config.model_family)
.get_full_instructions(&self.get_model_family())
.into_owned();
let payload = ApiCompactionInput {
model: &self.config.model,

View File

@@ -1,6 +1,6 @@
use crate::client_common::tools::ToolSpec;
use crate::error::Result;
use crate::model_family::ModelFamily;
use crate::openai_models::model_family::ModelFamily;
pub use codex_api::common::ResponseEvent;
use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
use codex_protocol::models::ResponseItem;
@@ -252,7 +252,7 @@ impl Stream for ResponseStream {
#[cfg(test)]
mod tests {
use crate::model_family::find_family_for_model;
use crate::openai_models::model_family::find_family_for_model;
use codex_api::ResponsesApiRequest;
use codex_api::common::OpenAiVerbosity;
use codex_api::common::TextControls;
@@ -309,7 +309,7 @@ mod tests {
},
];
for test_case in test_cases {
let model_family = find_family_for_model(test_case.slug).expect("known model slug");
let model_family = find_family_for_model(test_case.slug);
let expected = if test_case.expects_apply_patch_instructions {
format!(
"{}\n{}",

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@ use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::Config;
use crate::error::CodexErr;
use crate::openai_models::models_manager::ModelsManager;
use codex_protocol::protocol::InitialHistory;
/// Start an interactive sub-Codex conversation and return IO channels.
@@ -35,6 +36,7 @@ use codex_protocol::protocol::InitialHistory;
pub(crate) async fn run_codex_conversation_interactive(
config: Config,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
parent_session: Arc<Session>,
parent_ctx: Arc<TurnContext>,
cancel_token: CancellationToken,
@@ -46,6 +48,7 @@ pub(crate) async fn run_codex_conversation_interactive(
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
auth_manager,
models_manager,
initial_history.unwrap_or(InitialHistory::New),
SessionSource::SubAgent(SubAgentSource::Review),
)
@@ -88,9 +91,11 @@ pub(crate) async fn run_codex_conversation_interactive(
/// Convenience wrapper for one-time use with an initial prompt.
///
/// Internally calls the interactive variant, then immediately submits the provided input.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_codex_conversation_one_shot(
config: Config,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
input: Vec<UserInput>,
parent_session: Arc<Session>,
parent_ctx: Arc<TurnContext>,
@@ -103,6 +108,7 @@ pub(crate) async fn run_codex_conversation_one_shot(
let io = run_codex_conversation_interactive(
config,
auth_manager,
models_manager,
parent_session,
parent_ctx,
child_cancel.clone(),
@@ -275,6 +281,7 @@ async fn handle_exec_approval(
event.cwd,
event.reason,
event.risk,
event.proposed_execpolicy_amendment,
);
let decision = await_approval_with_cancel(
approval_fut,

View File

@@ -32,13 +32,13 @@ pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt
pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md");
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;
pub(crate) async fn should_use_remote_compact_task(session: &Session) -> bool {
pub(crate) fn should_use_remote_compact_task(session: &Session) -> bool {
session
.services
.auth_manager
.auth()
.is_some_and(|auth| auth.mode == AuthMode::ChatGPT)
&& session.enabled(Feature::RemoteCompaction).await
&& session.enabled(Feature::RemoteCompaction)
}
pub(crate) async fn run_inline_auto_compact_task(

View File

@@ -2,8 +2,8 @@ use crate::config::CONFIG_TOML_FILE;
use crate::config::types::McpServerConfig;
use crate::config::types::Notice;
use anyhow::Context;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
@@ -574,7 +574,7 @@ impl ConfigEditsBuilder {
mod tests {
use super::*;
use crate::config::types::McpServerTransportConfig;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use tokio::runtime::Builder;

Some files were not shown because too many files have changed in this diff Show More