chore(merge): prepare workspace for vitual cherry-pick

This commit is contained in:
James Peter
2025-10-01 04:11:40 +10:00
173 changed files with 16633 additions and 941 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl
skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt
check-hidden = true
ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b
ignore-words-list = ratatui,ser
+43
View File
@@ -0,0 +1,43 @@
name: sdk
on:
push:
branches: [main]
pull_request: {}
jobs:
sdks:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
- uses: dtolnay/[email protected]
- name: build codex
run: cargo build --bin codex
working-directory: codex-rs
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build SDK packages
run: pnpm -r --filter ./sdk/typescript run build
- name: Lint SDK packages
run: pnpm -r --filter ./sdk/typescript run lint
- name: Test SDK packages
run: pnpm -r --filter ./sdk/typescript run test
+392 -23
View File
@@ -191,6 +191,20 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "app_test_support"
version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"codex-protocol",
"mcp-types",
"serde",
"serde_json",
"tokio",
"wiremock",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -446,9 +460,9 @@ dependencies = [
[[package]]
name = "axum"
version = "0.8.4"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
checksum = "98e529aee37b5c8206bb4bf4c44797127566d72f76952c970bd3d1e85de8f4e2"
dependencies = [
"axum-core",
"bytes",
@@ -464,8 +478,7 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_core",
"sync_wrapper",
"tokio",
"tower",
@@ -475,9 +488,9 @@ dependencies = [
[[package]]
name = "axum-core"
version = "0.5.2"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6"
checksum = "0ac7a6beb1182c7e30253ee75c3e918080bfb83f5a3023bcdf7209d85fd147e6"
dependencies = [
"bytes",
"futures-core",
@@ -486,7 +499,6 @@ dependencies = [
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
@@ -865,6 +877,36 @@ dependencies = [
"tracing",
]
[[package]]
name = "codex-app-server"
version = "0.0.0"
dependencies = [
"anyhow",
"app_test_support",
"assert_cmd",
"base64 0.22.1",
"codex-arg0",
"codex-common",
"codex-core",
"codex-file-search",
"codex-login",
"codex-protocol",
"codex-utils-json-to-toml",
"core_test_support",
"mcp-types",
"os_info",
"pretty_assertions",
"serde",
"serde_json",
"tempfile",
"tokio",
"toml 0.9.7",
"tracing",
"tracing-subscriber",
"uuid",
"wiremock",
]
[[package]]
name = "codex-apply-patch"
version = "0.0.0"
@@ -892,6 +934,26 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-backend-client"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-backend-openapi-models",
"pretty_assertions",
"reqwest",
"serde",
"serde_json",
]
[[package]]
name = "codex-backend-openapi-models"
version = "0.0.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "codex-browser"
version = "0.0.0"
@@ -927,6 +989,7 @@ dependencies = [
"clap",
"codex-common",
"codex-core",
"codex-git-apply",
"codex-protocol",
"serde",
"serde_json",
@@ -942,8 +1005,10 @@ dependencies = [
"assert_cmd",
"clap",
"clap_complete",
"codex-app-server",
"codex-arg0",
"codex-chatgpt",
"codex-cloud-tasks",
"codex-common",
"codex-core",
"codex-exec",
@@ -975,6 +1040,48 @@ dependencies = [
"zip",
]
[[package]]
name = "codex-cloud-tasks"
version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"chrono",
"clap",
"codex-cloud-tasks-client",
"codex-common",
"codex-core",
"codex-login",
"codex-tui",
"crossterm",
"ratatui",
"reqwest",
"serde",
"serde_json",
"throbber-widgets-tui",
"tokio",
"tokio-stream",
"tracing",
"tracing-subscriber",
"unicode-width 0.1.14",
]
[[package]]
name = "codex-cloud-tasks-client"
version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"codex-backend-client",
"codex-git-apply",
"diffy",
"serde",
"serde_json",
"thiserror 2.0.16",
]
[[package]]
name = "codex-common"
version = "0.0.0"
@@ -1002,6 +1109,7 @@ dependencies = [
"codex-browser",
"codex-file-search",
"codex-mcp-client",
"codex-otel",
"codex-protocol",
"codex-rmcp-client",
"codex-version",
@@ -1071,6 +1179,7 @@ dependencies = [
"codex-protocol",
"core_test_support",
"libc",
"mcp-types",
"owo-colors",
"predicates",
"serde_json",
@@ -1117,6 +1226,15 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-git-apply"
version = "0.0.0"
dependencies = [
"once_cell",
"regex",
"tempfile",
]
[[package]]
name = "codex-git-tooling"
version = "0.0.0"
@@ -1161,6 +1279,7 @@ dependencies = [
"url",
"urlencoding",
"webbrowser",
"wiremock",
]
[[package]]
@@ -1183,12 +1302,12 @@ dependencies = [
"agent-client-protocol",
"anyhow",
"assert_cmd",
"base64 0.22.1",
"codex-app-server",
"codex-arg0",
"codex-common",
"codex-core",
"codex-login",
"codex-protocol",
"codex-utils-json-to-toml",
"core_test_support",
"mcp-types",
"mcp_test_support",
@@ -1200,7 +1319,6 @@ dependencies = [
"shlex",
"tempfile",
"tokio",
"toml 0.9.7",
"tracing",
"tracing-subscriber",
"uuid",
@@ -1222,6 +1340,26 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-otel"
version = "0.0.0"
dependencies = [
"chrono",
"codex-protocol",
"eventsource-stream",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry-semantic-conventions",
"opentelemetry_sdk",
"reqwest",
"serde",
"serde_json",
"strum_macros 0.27.2",
"tokio",
"tonic",
"tracing",
]
[[package]]
name = "codex-process-hardening"
version = "0.0.0"
@@ -1365,6 +1503,15 @@ dependencies = [
"which",
]
[[package]]
name = "codex-utils-json-to-toml"
version = "0.0.0"
dependencies = [
"pretty_assertions",
"serde_json",
"toml 0.9.7",
]
[[package]]
name = "codex-utils-readiness"
version = "0.0.0"
@@ -1587,6 +1734,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.9.4",
"crossterm_winapi",
"futures-core",
"mio",
"parking_lot",
"rustix 0.38.44",
@@ -2746,6 +2894,7 @@ dependencies = [
"hyper",
"hyper-util",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
@@ -2753,6 +2902,19 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
@@ -2787,7 +2949,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.6.0",
"system-configuration",
"tokio",
"tower-service",
@@ -3570,7 +3732,6 @@ dependencies = [
"assert_cmd",
"codex-core",
"codex-mcp-server",
"codex-protocol",
"mcp-types",
"os_info",
"pretty_assertions",
@@ -3670,7 +3831,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
@@ -4038,6 +4199,92 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "opentelemetry"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6"
dependencies = [
"futures-core",
"futures-sink",
"js-sys",
"pin-project-lite",
"thiserror 2.0.16",
"tracing",
]
[[package]]
name = "opentelemetry-http"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d"
dependencies = [
"async-trait",
"bytes",
"http",
"opentelemetry",
"reqwest",
]
[[package]]
name = "opentelemetry-otlp"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b"
dependencies = [
"http",
"opentelemetry",
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
"prost",
"reqwest",
"serde_json",
"thiserror 2.0.16",
"tokio",
"tonic",
"tracing",
]
[[package]]
name = "opentelemetry-proto"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e046fd7660710fe5a05e8748e70d9058dc15c94ba914e7c4faa7c728f0e8ddc"
dependencies = [
"base64 0.22.1",
"hex",
"opentelemetry",
"opentelemetry_sdk",
"prost",
"serde",
"tonic",
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d059a296a47436748557a353c5e6c5705b9470ef6c95cfc52c21a8814ddac2"
[[package]]
name = "opentelemetry_sdk"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b"
dependencies = [
"futures-channel",
"futures-executor",
"futures-util",
"opentelemetry",
"percent-encoding",
"rand 0.9.2",
"serde_json",
"thiserror 2.0.16",
"tokio",
"tokio-stream",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -4175,6 +4422,26 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -4377,6 +4644,29 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "prost"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.0"
@@ -4431,9 +4721,9 @@ dependencies = [
[[package]]
name = "quinn"
version = "0.11.9"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8"
dependencies = [
"bytes",
"cfg_aliases 0.2.1",
@@ -4442,7 +4732,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.5.10",
"thiserror 2.0.16",
"tokio",
"tracing",
@@ -4451,9 +4741,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.13"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e"
dependencies = [
"bytes",
"getrandom 0.3.3",
@@ -4472,16 +4762,16 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.14"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970"
dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2",
"socket2 0.5.10",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4801,6 +5091,7 @@ dependencies = [
"pin-project-lite",
"quinn",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"serde",
"serde_json",
@@ -4999,6 +5290,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework 3.5.1",
]
[[package]]
name = "rustls-pki-types"
version = "1.12.0"
@@ -5204,6 +5507,19 @@ dependencies = [
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
@@ -5516,6 +5832,16 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.0"
@@ -6007,6 +6333,16 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "throbber-widgets-tui"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d36b5738d666a2b4c91b7c24998a8588db724b3107258343ebf8824bf55b06d"
dependencies = [
"rand 0.8.5",
"ratatui",
]
[[package]]
name = "tiff"
version = "0.10.3"
@@ -6115,7 +6451,7 @@ dependencies = [
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"socket2 0.6.0",
"tokio-macros",
"windows-sys 0.59.0",
]
@@ -6275,6 +6611,35 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109"
[[package]]
name = "tonic"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-timeout",
"hyper-util",
"percent-encoding",
"pin-project",
"prost",
"socket2 0.5.10",
"tokio",
"tokio-stream",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower"
version = "0.5.2"
@@ -6283,11 +6648,15 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
dependencies = [
"futures-core",
"futures-util",
"indexmap 2.11.4",
"pin-project-lite",
"slab",
"sync_wrapper",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
+21
View File
@@ -1,8 +1,13 @@
[workspace]
members = [
"backend-client",
"ansi-escape",
"app-server",
"apply-patch",
"arg0",
"codex-backend-openapi-models",
"cloud-tasks",
"cloud-tasks-client",
"cli",
"common",
"core",
@@ -21,7 +26,10 @@ members = [
"protocol-ts",
"rmcp-client",
"responses-api-proxy",
"otel",
"tui",
"git-apply",
"utils/json-to-toml",
"utils/readiness",
]
resolver = "2"
@@ -36,12 +44,16 @@ edition = "2024"
[workspace.dependencies]
# Internal
app_test_support = { path = "app-server/tests/common" }
codex-ansi-escape = { path = "ansi-escape" }
codex-app-server = { path = "app-server" }
codex-apply-patch = { path = "apply-patch" }
codex-arg0 = { path = "arg0" }
codex-chatgpt = { path = "chatgpt" }
codex-common = { path = "common" }
codex-core = { path = "core" }
codex-cloud-tasks = { path = "cloud-tasks" }
codex-cloud-tasks-client = { path = "cloud-tasks-client" }
codex-exec = { path = "exec" }
codex-file-search = { path = "file-search" }
codex-git-tooling = { path = "git-tooling" }
@@ -54,7 +66,9 @@ codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }
codex-protocol-ts = { path = "protocol-ts" }
codex-rmcp-client = { path = "rmcp-client" }
codex-otel = { path = "otel" }
codex-tui = { path = "tui" }
codex-utils-json-to-toml = { path = "utils/json-to-toml" }
codex-utils-readiness = { path = "utils/readiness" }
core_test_support = { path = "core/tests/common" }
mcp-types = { path = "mcp-types" }
@@ -103,6 +117,11 @@ mime_guess = "2.0.5"
multimap = "0.10.0"
nucleo-matcher = "0.3.1"
openssl-sys = "*"
opentelemetry = "0.30.0"
opentelemetry-appender-tracing = "0.30.0"
opentelemetry-otlp = "0.30.0"
opentelemetry-semantic-conventions = "0.30.0"
opentelemetry_sdk = "0.30.0"
os_info = "3.12.0"
owo-colors = "4.2.0"
path-absolutize = "3.1.1"
@@ -141,9 +160,11 @@ tokio-test = "0.4"
tokio-util = "0.7.16"
toml = "0.9.5"
toml_edit = "0.23.4"
tonic = "0.13.1"
tracing = "0.1.41"
tracing-appender = "0.2.3"
tracing-subscriber = "0.3.20"
tracing-test = "0.2.5"
tree-sitter = "0.25.9"
tree-sitter-bash = "0.25.0"
ts-rs = "11"
+4 -2
View File
@@ -25,12 +25,14 @@ Codex supports a rich set of configuration options. Note that the Rust CLI uses
Codex CLI functions as an MCP client that can connect to MCP servers on startup. See the [`mcp_servers`](../docs/config.md#mcp_servers) section in the configuration documentation for details.
It is still experimental, but you can also launch Codex as an MCP _server_ by running `codex mcp`. Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out:
It is still experimental, but you can also launch Codex as an MCP _server_ by running `codex mcp-server`. Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out:
```shell
npx @modelcontextprotocol/inspector codex mcp
npx @modelcontextprotocol/inspector codex mcp-server
```
Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.toml`, and `codex mcp-server` to run the MCP server directly.
### Notifications
You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS.
+51
View File
@@ -0,0 +1,51 @@
[package]
edition = "2024"
name = "codex-app-server"
version = { workspace = true }
[[bin]]
name = "codex-app-server"
path = "src/main.rs"
[lib]
name = "codex_app_server"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-arg0 = { workspace = true }
codex-common = { workspace = true, features = ["cli"] }
codex-core = { workspace = true }
codex-file-search = { workspace = true }
codex-login = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
# We should only be using mcp-types for JSON-RPC types: it would be nice to
# split this out into a separate crate at some point.
mcp-types = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
"process",
"rt-multi-thread",
"signal",
] }
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
uuid = { workspace = true, features = ["serde", "v7"] }
[dev-dependencies]
app_test_support = { workspace = true }
assert_cmd = { workspace = true }
base64 = { workspace = true }
core_test_support = { workspace = true }
os_info = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
toml = { workspace = true }
wiremock = { workspace = true }
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use codex_core::AuthManager;
use codex_core::CodexConversation;
@@ -13,6 +14,8 @@ use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_protocol::mcp_protocol::FuzzyFileSearchParams;
use codex_protocol::mcp_protocol::FuzzyFileSearchResponse;
use codex_protocol::protocol::ReviewDecision;
use mcp_types::JSONRPCErrorError;
use mcp_types::RequestId;
@@ -23,9 +26,10 @@ use uuid::Uuid;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::json_to_toml::json_to_toml;
use codex_utils_json_to_toml::json_to_toml;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotification;
use crate::fuzzy_file_search::run_fuzzy_file_search;
use codex_protocol::protocol::TurnAbortReason;
use codex_core::protocol::InputItem as CoreInputItem;
use codex_core::protocol::Op;
@@ -57,7 +61,7 @@ use codex_protocol::mcp_protocol::SendUserTurnResponse;
// Removed deprecated ChatGPT login support scaffolding
/// Handles JSON-RPC messages for Codex conversations.
pub(crate) struct CodexMessageProcessor {
pub struct CodexMessageProcessor {
_auth_manager: Arc<AuthManager>,
conversation_manager: Arc<ConversationManager>,
outgoing: Arc<OutgoingMessageSender>,
@@ -66,6 +70,8 @@ pub(crate) struct CodexMessageProcessor {
conversation_listeners: HashMap<Uuid, oneshot::Sender<()>>,
// Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives.
pending_interrupts: Arc<Mutex<HashMap<Uuid, Vec<RequestId>>>>,
#[allow(dead_code)]
pending_fuzzy_searches: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
}
impl CodexMessageProcessor {
@@ -84,11 +90,15 @@ impl CodexMessageProcessor {
_config: config,
conversation_listeners: HashMap::new(),
pending_interrupts: Arc::new(Mutex::new(HashMap::new())),
pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn process_request(&mut self, request: ClientRequest) {
match request {
ClientRequest::Initialize { .. } => {
panic!("Initialize should be handled in MessageProcessor");
}
ClientRequest::NewConversation { request_id, params } => {
// Do not tokio::spawn() to process new_conversation()
// asynchronously because we need to ensure the conversation is
@@ -427,6 +437,47 @@ impl CodexMessageProcessor {
}
}
}
#[allow(dead_code)]
async fn fuzzy_file_search(&mut self, request_id: RequestId, params: FuzzyFileSearchParams) {
let FuzzyFileSearchParams {
query,
roots,
cancellation_token,
} = params;
let cancel_flag = match cancellation_token.clone() {
Some(token) => {
let mut pending_fuzzy_searches = self.pending_fuzzy_searches.lock().await;
// if a cancellation_token is provided and a pending_request exists for
// that token, cancel it
if let Some(existing) = pending_fuzzy_searches.get(&token) {
existing.store(true, Ordering::Relaxed);
}
let flag = Arc::new(AtomicBool::new(false));
pending_fuzzy_searches.insert(token.clone(), flag.clone());
flag
}
None => Arc::new(AtomicBool::new(false)),
};
let results = match query.as_str() {
"" => vec![],
_ => run_fuzzy_file_search(query, roots, cancel_flag.clone()).await,
};
if let Some(token) = cancellation_token {
let mut pending_fuzzy_searches = self.pending_fuzzy_searches.lock().await;
if let Some(current_flag) = pending_fuzzy_searches.get(&token)
&& Arc::ptr_eq(current_flag, &cancel_flag)
{
pending_fuzzy_searches.remove(&token);
}
}
let response = FuzzyFileSearchResponse { files: results };
self.outgoing.send_response(request_id, response).await;
}
}
impl CodexMessageProcessor {
+2
View File
@@ -0,0 +1,2 @@
pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603;
@@ -0,0 +1,88 @@
use std::num::NonZero;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use codex_file_search as file_search;
use codex_protocol::mcp_protocol::FuzzyFileSearchResult;
use tokio::task::JoinSet;
use tracing::warn;
#[allow(dead_code)]
const LIMIT_PER_ROOT: usize = 50;
#[allow(dead_code)]
const MAX_THREADS: usize = 12;
#[allow(dead_code)]
const COMPUTE_INDICES: bool = true;
#[allow(dead_code)]
pub(crate) async fn run_fuzzy_file_search(
query: String,
roots: Vec<String>,
cancellation_flag: Arc<AtomicBool>,
) -> Vec<FuzzyFileSearchResult> {
#[expect(clippy::expect_used)]
let limit_per_root =
NonZero::new(LIMIT_PER_ROOT).expect("LIMIT_PER_ROOT should be a valid non-zero usize");
let cores = std::thread::available_parallelism()
.map(std::num::NonZero::get)
.unwrap_or(1);
let threads = cores.min(MAX_THREADS);
let threads_per_root = (threads / roots.len()).max(1);
let threads = NonZero::new(threads_per_root).unwrap_or(NonZeroUsize::MIN);
let mut files: Vec<FuzzyFileSearchResult> = Vec::new();
let mut join_set = JoinSet::new();
for root in roots {
let search_dir = PathBuf::from(&root);
let query = query.clone();
let cancel_flag = cancellation_flag.clone();
join_set.spawn_blocking(move || {
match file_search::run(
query.as_str(),
limit_per_root,
&search_dir,
Vec::new(),
threads,
cancel_flag,
COMPUTE_INDICES,
) {
Ok(res) => Ok((root, res)),
Err(err) => Err((root, err)),
}
});
}
while let Some(res) = join_set.join_next().await {
match res {
Ok(Ok((root, res))) => {
for m in res.matches {
let result = FuzzyFileSearchResult {
root: root.clone(),
path: m.path,
score: m.score,
indices: m.indices,
};
files.push(result);
}
}
Ok(Err((root, err))) => {
warn!("fuzzy-file-search in dir '{root}' failed: {err}");
}
Err(err) => {
warn!("fuzzy-file-search join_next failed: {err}");
}
}
}
files.sort_by(file_search::cmp_by_score_desc_then_path_asc::<
FuzzyFileSearchResult,
_,
_,
>(|f| f.score, |f| f.path.as_str()));
files
}
+140
View File
@@ -0,0 +1,140 @@
#![deny(clippy::print_stdout, clippy::print_stderr)]
use std::io::ErrorKind;
use std::io::Result as IoResult;
use std::path::PathBuf;
use codex_common::CliConfigOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use mcp_types::JSONRPCMessage;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::io::{self};
use tokio::sync::mpsc;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing_subscriber::EnvFilter;
use crate::message_processor::MessageProcessor;
use crate::outgoing_message::OutgoingMessage;
use crate::outgoing_message::OutgoingMessageSender;
pub mod codex_message_processor;
mod error_code;
mod fuzzy_file_search;
mod message_processor;
pub mod outgoing_message;
/// Size of the bounded channels used to communicate between tasks. The value
/// is a balance between throughput and memory usage – 128 messages should be
/// plenty for an interactive CLI.
const CHANNEL_CAPACITY: usize = 128;
pub async fn run_main(
codex_linux_sandbox_exe: Option<PathBuf>,
cli_config_overrides: CliConfigOverrides,
) -> IoResult<()> {
// Install a simple subscriber so `tracing` output is visible. Users can
// control the log level with `RUST_LOG`.
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(EnvFilter::from_default_env())
.init();
// Set up channels.
let (incoming_tx, mut incoming_rx) = mpsc::channel::<JSONRPCMessage>(CHANNEL_CAPACITY);
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
// Task: read from stdin, push to `incoming_tx`.
let stdin_reader_handle = tokio::spawn({
async move {
let stdin = io::stdin();
let reader = BufReader::new(stdin);
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await.unwrap_or_default() {
match serde_json::from_str::<JSONRPCMessage>(&line) {
Ok(msg) => {
if incoming_tx.send(msg).await.is_err() {
// Receiver gone – nothing left to do.
break;
}
}
Err(e) => error!("Failed to deserialize JSONRPCMessage: {e}"),
}
}
debug!("stdin reader finished (EOF)");
}
});
// Parse CLI overrides once and derive the base Config eagerly so later
// components do not need to work with raw TOML values.
let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidInput,
format!("error parsing -c overrides: {e}"),
)
})?;
let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default())
.map_err(|e| {
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
})?;
// Task: process incoming messages.
let processor_handle = tokio::spawn({
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
let mut processor = MessageProcessor::new(
outgoing_message_sender,
codex_linux_sandbox_exe,
std::sync::Arc::new(config),
);
async move {
while let Some(msg) = incoming_rx.recv().await {
match msg {
JSONRPCMessage::Request(r) => processor.process_request(r).await,
JSONRPCMessage::Response(r) => processor.process_response(r).await,
JSONRPCMessage::Notification(n) => processor.process_notification(n).await,
JSONRPCMessage::Error(e) => processor.process_error(e),
}
}
info!("processor task exited (channel closed)");
}
});
// Task: write outgoing messages to stdout.
let stdout_writer_handle = tokio::spawn(async move {
let mut stdout = io::stdout();
while let Some(outgoing_message) = outgoing_rx.recv().await {
let msg: JSONRPCMessage = outgoing_message.into();
match serde_json::to_string(&msg) {
Ok(json) => {
if let Err(e) = stdout.write_all(json.as_bytes()).await {
error!("Failed to write to stdout: {e}");
break;
}
if let Err(e) = stdout.write_all(b"\n").await {
error!("Failed to write newline to stdout: {e}");
break;
}
}
Err(e) => error!("Failed to serialize JSONRPCMessage: {e}"),
}
}
info!("stdout writer exited (channel closed)");
});
// Wait for all tasks to finish. The typical exit path is the stdin reader
// hitting EOF which, once it drops `incoming_tx`, propagates shutdown to
// the processor and then to the stdout task.
let _ = tokio::join!(stdin_reader_handle, processor_handle, stdout_writer_handle);
Ok(())
}
+10
View File
@@ -0,0 +1,10 @@
use codex_app_server::run_main;
use codex_arg0::arg0_dispatch_or_else;
use codex_common::CliConfigOverrides;
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
run_main(codex_linux_sandbox_exe, CliConfigOverrides::default()).await?;
Ok(())
})
}
@@ -0,0 +1,143 @@
use std::path::PathBuf;
use crate::codex_message_processor::CodexMessageProcessor;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::ClientInfo;
use codex_protocol::mcp_protocol::ClientRequest;
use codex_protocol::mcp_protocol::InitializeResponse;
use codex_core::AuthManager;
use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::default_client::USER_AGENT_SUFFIX;
use codex_core::default_client::get_codex_user_agent;
use mcp_types::JSONRPCError;
use mcp_types::JSONRPCErrorError;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use std::sync::Arc;
pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
codex_message_processor: CodexMessageProcessor,
base_config: Arc<Config>,
initialized: bool,
}
impl MessageProcessor {
/// Create a new `MessageProcessor`, retaining a handle to the outgoing
/// `Sender` so handlers can enqueue messages to be written to stdout.
pub(crate) fn new(
outgoing: OutgoingMessageSender,
codex_linux_sandbox_exe: Option<PathBuf>,
config: Arc<Config>,
) -> Self {
let outgoing = Arc::new(outgoing);
let auth_manager = AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
AuthMode::ApiKey,
config.responses_originator_header.clone(),
);
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let config_for_processor = config.clone();
let codex_message_processor = CodexMessageProcessor::new(
auth_manager,
conversation_manager,
outgoing.clone(),
codex_linux_sandbox_exe,
config_for_processor.clone(),
);
Self {
outgoing,
codex_message_processor,
base_config: config_for_processor,
initialized: false,
}
}
pub(crate) async fn process_request(&mut self, request: JSONRPCRequest) {
let request_id = request.id.clone();
if let Ok(request_json) = serde_json::to_value(request)
&& let Ok(codex_request) = serde_json::from_value::<ClientRequest>(request_json)
{
match codex_request {
// Handle Initialize internally so CodexMessageProcessor does not have to concern
// itself with the `initialized` bool.
ClientRequest::Initialize { request_id, params } => {
if self.initialized {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "Already initialized".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
} else {
let ClientInfo {
name,
title: _title,
version,
} = params.client_info;
let user_agent_suffix = format!("{name}; {version}");
if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() {
*suffix = Some(user_agent_suffix);
}
let user_agent = get_codex_user_agent(Some(
&self.base_config.responses_originator_header,
));
let response = InitializeResponse { user_agent };
self.outgoing.send_response(request_id, response).await;
self.initialized = true;
return;
}
}
_ => {
if !self.initialized {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "Not initialized".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
}
}
self.codex_message_processor
.process_request(codex_request)
.await;
} else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "Invalid request".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
}
pub(crate) async fn process_notification(&self, notification: JSONRPCNotification) {
// Currently, we do not expect to receive any notifications from the
// client, so we just log them.
tracing::info!("<- notification: {:?}", notification);
}
/// Handle a standalone JSON-RPC response originating from the peer.
pub(crate) async fn process_response(&mut self, response: JSONRPCResponse) {
tracing::info!("<- response: {:?}", response);
let JSONRPCResponse { id, result, .. } = response;
self.outgoing.notify_client_response(id, result).await
}
/// Handle an error object received from the peer.
pub(crate) fn process_error(&mut self, err: JSONRPCError) {
tracing::error!("<- error: {:?}", err);
}
}
+179
View File
@@ -0,0 +1,179 @@
use std::collections::HashMap;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
use mcp_types::JSONRPC_VERSION;
use mcp_types::JSONRPCError;
use mcp_types::JSONRPCErrorError;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use mcp_types::Result;
use serde::Serialize;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tracing::warn;
use crate::error_code::INTERNAL_ERROR_CODE;
/// Sends messages to the client and manages request callbacks.
pub struct OutgoingMessageSender {
next_request_id: AtomicI64,
sender: mpsc::UnboundedSender<OutgoingMessage>,
request_id_to_callback: Mutex<HashMap<RequestId, oneshot::Sender<Result>>>,
}
impl OutgoingMessageSender {
pub fn new(sender: mpsc::UnboundedSender<OutgoingMessage>) -> Self {
Self {
next_request_id: AtomicI64::new(0),
sender,
request_id_to_callback: Mutex::new(HashMap::new()),
}
}
pub async fn send_request(
&self,
method: &str,
params: Option<serde_json::Value>,
) -> oneshot::Receiver<Result> {
let id = RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed));
let outgoing_message_id = id.clone();
let (tx_approve, rx_approve) = oneshot::channel();
{
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
request_id_to_callback.insert(id, tx_approve);
}
let outgoing_message = OutgoingMessage::Request(OutgoingRequest {
id: outgoing_message_id,
method: method.to_string(),
params,
});
let _ = self.sender.send(outgoing_message);
rx_approve
}
pub async fn notify_client_response(&self, id: RequestId, result: Result) {
let entry = {
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
request_id_to_callback.remove_entry(&id)
};
match entry {
Some((id, sender)) => {
if let Err(err) = sender.send(result) {
warn!("could not notify callback for {id:?} due to: {err:?}");
}
}
None => {
warn!("could not find callback for {id:?}");
}
}
}
pub async fn send_response<T: Serialize>(&self, id: RequestId, response: T) {
match serde_json::to_value(response) {
Ok(result) => {
let outgoing_message = OutgoingMessage::Response(OutgoingResponse { id, result });
let _ = self.sender.send(outgoing_message);
}
Err(err) => {
self.send_error(
id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to serialize response: {err}"),
data: None,
},
)
.await;
}
}
}
/// All notifications should be migrated to [`ServerNotification`] and
/// [`OutgoingMessage::Notification`] should be removed.
pub async fn send_notification(&self, notification: OutgoingNotification) {
let outgoing_message = OutgoingMessage::Notification(notification);
let _ = self.sender.send(outgoing_message);
}
pub async fn send_error(&self, id: RequestId, error: JSONRPCErrorError) {
let outgoing_message = OutgoingMessage::Error(OutgoingError { id, error });
let _ = self.sender.send(outgoing_message);
}
}
/// Outgoing message from the server to the client.
pub enum OutgoingMessage {
Request(OutgoingRequest),
Notification(OutgoingNotification),
Response(OutgoingResponse),
Error(OutgoingError),
}
impl From<OutgoingMessage> for JSONRPCMessage {
fn from(val: OutgoingMessage) -> Self {
use OutgoingMessage::*;
match val {
Request(OutgoingRequest { id, method, params }) => {
JSONRPCMessage::Request(JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
id,
method,
params,
})
}
Notification(OutgoingNotification { method, params }) => {
JSONRPCMessage::Notification(JSONRPCNotification {
jsonrpc: JSONRPC_VERSION.into(),
method,
params,
})
}
Response(OutgoingResponse { id, result }) => {
JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id,
result,
})
}
Error(OutgoingError { id, error }) => JSONRPCMessage::Error(JSONRPCError {
jsonrpc: JSONRPC_VERSION.into(),
id,
error,
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutgoingRequest {
pub id: RequestId,
pub method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutgoingNotification {
pub method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutgoingResponse {
pub id: RequestId,
pub result: Result,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutgoingError {
pub error: JSONRPCErrorError,
pub id: RequestId,
}
+3
View File
@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;
@@ -0,0 +1,22 @@
[package]
edition = "2024"
name = "app_test_support"
version = { workspace = true }
[lib]
path = "lib.rs"
[dependencies]
anyhow = { workspace = true }
assert_cmd = { workspace = true }
codex-protocol = { workspace = true }
mcp-types = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
"process",
"rt-multi-thread",
] }
wiremock = { workspace = true }
+17
View File
@@ -0,0 +1,17 @@
mod mcp_process;
mod mock_model_server;
mod responses;
pub use mcp_process::McpProcess;
use mcp_types::JSONRPCResponse;
pub use mock_model_server::create_mock_chat_completions_server;
pub use responses::create_apply_patch_sse_response;
pub use responses::create_final_assistant_message_sse_response;
pub use responses::create_shell_sse_response;
use serde::de::DeserializeOwned;
pub fn to_response<T: DeserializeOwned>(response: JSONRPCResponse) -> anyhow::Result<T> {
let value = serde_json::to_value(response.result)?;
let codex_response = serde_json::from_value(value)?;
Ok(codex_response)
}
@@ -0,0 +1,477 @@
use std::path::Path;
use std::process::Stdio;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio::process::ChildStdin;
use tokio::process::ChildStdout;
use anyhow::Context;
use assert_cmd::prelude::*;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::ArchiveConversationParams;
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
use codex_protocol::mcp_protocol::ClientInfo;
use codex_protocol::mcp_protocol::ClientNotification;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::InitializeParams;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::ListConversationsParams;
use codex_protocol::mcp_protocol::LoginApiKeyParams;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
use codex_protocol::mcp_protocol::ResumeConversationParams;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserTurnParams;
use codex_protocol::mcp_protocol::SetDefaultModelParams;
use mcp_types::JSONRPC_VERSION;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use std::process::Command as StdCommand;
use tokio::process::Command;
pub struct McpProcess {
next_request_id: AtomicI64,
/// Retain this child process until the client is dropped. The Tokio runtime
/// will make a "best effort" to reap the process after it exits, but it is
/// not a guarantee. See the `kill_on_drop` documentation for details.
#[allow(dead_code)]
process: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
}
impl McpProcess {
pub async fn new(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_env(codex_home, &[]).await
}
/// Creates a new MCP process, allowing tests to override or remove
/// specific environment variables for the child process only.
///
/// Pass a tuple of (key, Some(value)) to set/override, or (key, None) to
/// remove a variable from the child's environment.
pub async fn new_with_env(
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
// Use assert_cmd to locate the binary path and then switch to tokio::process::Command
let std_cmd = StdCommand::cargo_bin("codex-app-server")
.context("should find binary for codex-mcp-server")?;
let program = std_cmd.get_program().to_owned();
let mut cmd = Command::new(program);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.env("CODEX_HOME", codex_home);
cmd.env("RUST_LOG", "debug");
for (k, v) in env_overrides {
match v {
Some(val) => {
cmd.env(k, val);
}
None => {
cmd.env_remove(k);
}
}
}
let mut process = cmd
.kill_on_drop(true)
.spawn()
.context("codex-mcp-server proc should start")?;
let stdin = process
.stdin
.take()
.ok_or_else(|| anyhow::format_err!("mcp should have stdin fd"))?;
let stdout = process
.stdout
.take()
.ok_or_else(|| anyhow::format_err!("mcp should have stdout fd"))?;
let stdout = BufReader::new(stdout);
// Forward child's stderr to our stderr so failures are visible even
// when stdout/stderr are captured by the test harness.
if let Some(stderr) = process.stderr.take() {
let mut stderr_reader = BufReader::new(stderr).lines();
tokio::spawn(async move {
while let Ok(Some(line)) = stderr_reader.next_line().await {
eprintln!("[mcp stderr] {line}");
}
});
}
Ok(Self {
next_request_id: AtomicI64::new(0),
process,
stdin,
stdout,
})
}
/// Performs the initialization handshake with the MCP server.
pub async fn initialize(&mut self) -> anyhow::Result<()> {
let params = Some(serde_json::to_value(InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
})?);
let req_id = self.send_request("initialize", params).await?;
let initialized = self.read_jsonrpc_message().await?;
let JSONRPCMessage::Response(response) = initialized else {
unreachable!("expected JSONRPCMessage::Response for initialize, got {initialized:?}");
};
if response.id != RequestId::Integer(req_id) {
anyhow::bail!(
"initialize response id mismatch: expected {}, got {:?}",
req_id,
response.id
);
}
// Send notifications/initialized to ack the response.
self.send_notification(ClientNotification::Initialized)
.await?;
Ok(())
}
/// Send a `newConversation` JSON-RPC request.
pub async fn send_new_conversation_request(
&mut self,
params: NewConversationParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("newConversation", params).await
}
/// Send an `archiveConversation` JSON-RPC request.
pub async fn send_archive_conversation_request(
&mut self,
params: ArchiveConversationParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("archiveConversation", params).await
}
/// Send an `addConversationListener` JSON-RPC request.
pub async fn send_add_conversation_listener_request(
&mut self,
params: AddConversationListenerParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("addConversationListener", params).await
}
/// Send a `sendUserMessage` JSON-RPC request with a single text item.
pub async fn send_send_user_message_request(
&mut self,
params: SendUserMessageParams,
) -> anyhow::Result<i64> {
// Wire format expects variants in camelCase; text item uses external tagging.
let params = Some(serde_json::to_value(params)?);
self.send_request("sendUserMessage", params).await
}
/// Send a `removeConversationListener` JSON-RPC request.
pub async fn send_remove_conversation_listener_request(
&mut self,
params: RemoveConversationListenerParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("removeConversationListener", params)
.await
}
/// Send a `sendUserTurn` JSON-RPC request.
pub async fn send_send_user_turn_request(
&mut self,
params: SendUserTurnParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("sendUserTurn", params).await
}
/// Send a `interruptConversation` JSON-RPC request.
pub async fn send_interrupt_conversation_request(
&mut self,
params: InterruptConversationParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("interruptConversation", params).await
}
/// Send a `getAuthStatus` JSON-RPC request.
pub async fn send_get_auth_status_request(
&mut self,
params: GetAuthStatusParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("getAuthStatus", params).await
}
/// Send a `getUserSavedConfig` JSON-RPC request.
pub async fn send_get_user_saved_config_request(&mut self) -> anyhow::Result<i64> {
self.send_request("getUserSavedConfig", None).await
}
/// Send a `getUserAgent` JSON-RPC request.
pub async fn send_get_user_agent_request(&mut self) -> anyhow::Result<i64> {
self.send_request("getUserAgent", None).await
}
/// Send a `userInfo` JSON-RPC request.
pub async fn send_user_info_request(&mut self) -> anyhow::Result<i64> {
self.send_request("userInfo", None).await
}
/// Send a `setDefaultModel` JSON-RPC request.
pub async fn send_set_default_model_request(
&mut self,
params: SetDefaultModelParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("setDefaultModel", params).await
}
/// Send a `listConversations` JSON-RPC request.
pub async fn send_list_conversations_request(
&mut self,
params: ListConversationsParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("listConversations", params).await
}
/// Send a `resumeConversation` JSON-RPC request.
pub async fn send_resume_conversation_request(
&mut self,
params: ResumeConversationParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("resumeConversation", params).await
}
/// Send a `loginApiKey` JSON-RPC request.
pub async fn send_login_api_key_request(
&mut self,
params: LoginApiKeyParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("loginApiKey", params).await
}
/// Send a `loginChatGpt` JSON-RPC request.
pub async fn send_login_chat_gpt_request(&mut self) -> anyhow::Result<i64> {
self.send_request("loginChatGpt", None).await
}
/// Send a `cancelLoginChatGpt` JSON-RPC request.
pub async fn send_cancel_login_chat_gpt_request(
&mut self,
params: CancelLoginChatGptParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("cancelLoginChatGpt", params).await
}
/// Send a `logoutChatGpt` JSON-RPC request.
pub async fn send_logout_chat_gpt_request(&mut self) -> anyhow::Result<i64> {
self.send_request("logoutChatGpt", None).await
}
/// Send a `fuzzyFileSearch` JSON-RPC request.
pub async fn send_fuzzy_file_search_request(
&mut self,
query: &str,
roots: Vec<String>,
cancellation_token: Option<String>,
) -> anyhow::Result<i64> {
let mut params = serde_json::json!({
"query": query,
"roots": roots,
});
if let Some(token) = cancellation_token {
params["cancellationToken"] = serde_json::json!(token);
}
self.send_request("fuzzyFileSearch", Some(params)).await
}
async fn send_request(
&mut self,
method: &str,
params: Option<serde_json::Value>,
) -> anyhow::Result<i64> {
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let message = JSONRPCMessage::Request(JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(request_id),
method: method.to_string(),
params,
});
self.send_jsonrpc_message(message).await?;
Ok(request_id)
}
pub async fn send_response(
&mut self,
id: RequestId,
result: serde_json::Value,
) -> anyhow::Result<()> {
self.send_jsonrpc_message(JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id,
result,
}))
.await
}
pub async fn send_notification(
&mut self,
notification: ClientNotification,
) -> anyhow::Result<()> {
let value = serde_json::to_value(notification)?;
self.send_jsonrpc_message(JSONRPCMessage::Notification(JSONRPCNotification {
jsonrpc: JSONRPC_VERSION.into(),
method: value
.get("method")
.and_then(|m| m.as_str())
.ok_or_else(|| anyhow::format_err!("notification missing method field"))?
.to_string(),
params: value.get("params").cloned(),
}))
.await
}
async fn send_jsonrpc_message(&mut self, message: JSONRPCMessage) -> anyhow::Result<()> {
eprintln!("writing message to stdin: {message:?}");
let payload = serde_json::to_string(&message)?;
self.stdin.write_all(payload.as_bytes()).await?;
self.stdin.write_all(b"\n").await?;
self.stdin.flush().await?;
Ok(())
}
async fn read_jsonrpc_message(&mut self) -> anyhow::Result<JSONRPCMessage> {
let mut line = String::new();
self.stdout.read_line(&mut line).await?;
let message = serde_json::from_str::<JSONRPCMessage>(&line)?;
eprintln!("read message from stdout: {message:?}");
Ok(message)
}
pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result<JSONRPCRequest> {
eprintln!("in read_stream_until_request_message()");
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(_) => {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(jsonrpc_request) => {
return Ok(jsonrpc_request);
}
JSONRPCMessage::Error(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}");
}
JSONRPCMessage::Response(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Response: {message:?}");
}
}
}
}
pub async fn read_stream_until_response_message(
&mut self,
request_id: RequestId,
) -> anyhow::Result<JSONRPCResponse> {
eprintln!("in read_stream_until_response_message({request_id:?})");
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(_) => {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}");
}
JSONRPCMessage::Error(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}");
}
JSONRPCMessage::Response(jsonrpc_response) => {
if jsonrpc_response.id == request_id {
return Ok(jsonrpc_response);
}
}
}
}
}
pub async fn read_stream_until_error_message(
&mut self,
request_id: RequestId,
) -> anyhow::Result<mcp_types::JSONRPCError> {
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(_) => {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}");
}
JSONRPCMessage::Response(_) => {
// Keep scanning; we're waiting for an error with matching id.
}
JSONRPCMessage::Error(err) => {
if err.id == request_id {
return Ok(err);
}
}
}
}
}
pub async fn read_stream_until_notification_message(
&mut self,
method: &str,
) -> anyhow::Result<JSONRPCNotification> {
eprintln!("in read_stream_until_notification_message({method})");
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(notification) => {
if notification.method == method {
return Ok(notification);
}
}
JSONRPCMessage::Request(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}");
}
JSONRPCMessage::Error(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}");
}
JSONRPCMessage::Response(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Response: {message:?}");
}
}
}
}
}
@@ -0,0 +1,47 @@
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::Respond;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
/// Create a mock server that will provide the responses, in order, for
/// requests to the `/v1/chat/completions` endpoint.
pub async fn create_mock_chat_completions_server(responses: Vec<String>) -> MockServer {
let server = MockServer::start().await;
let num_calls = responses.len();
let seq_responder = SeqResponder {
num_calls: AtomicUsize::new(0),
responses,
};
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(seq_responder)
.expect(num_calls as u64)
.mount(&server)
.await;
server
}
struct SeqResponder {
num_calls: AtomicUsize,
responses: Vec<String>,
}
impl Respond for SeqResponder {
fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
let call_num = self.num_calls.fetch_add(1, Ordering::SeqCst);
match self.responses.get(call_num) {
Some(response) => ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(response.clone(), "text/event-stream"),
None => panic!("no response for {call_num}"),
}
}
}
@@ -0,0 +1,95 @@
use serde_json::json;
use std::path::Path;
pub fn create_shell_sse_response(
command: Vec<String>,
workdir: Option<&Path>,
timeout_ms: Option<u64>,
call_id: &str,
) -> anyhow::Result<String> {
// The `arguments`` for the `shell` tool is a serialized JSON object.
let tool_call_arguments = serde_json::to_string(&json!({
"command": command,
"workdir": workdir.map(|w| w.to_string_lossy()),
"timeout": timeout_ms
}))?;
let tool_call = json!({
"choices": [
{
"delta": {
"tool_calls": [
{
"id": call_id,
"function": {
"name": "shell",
"arguments": tool_call_arguments
}
}
]
},
"finish_reason": "tool_calls"
}
]
});
let sse = format!(
"data: {}\n\ndata: DONE\n\n",
serde_json::to_string(&tool_call)?
);
Ok(sse)
}
pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Result<String> {
let assistant_message = json!({
"choices": [
{
"delta": {
"content": message
},
"finish_reason": "stop"
}
]
});
let sse = format!(
"data: {}\n\ndata: DONE\n\n",
serde_json::to_string(&assistant_message)?
);
Ok(sse)
}
pub fn create_apply_patch_sse_response(
patch_content: &str,
call_id: &str,
) -> anyhow::Result<String> {
// Use shell command to call apply_patch with heredoc format
let shell_command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF");
let tool_call_arguments = serde_json::to_string(&json!({
"command": ["bash", "-lc", shell_command]
}))?;
let tool_call = json!({
"choices": [
{
"delta": {
"tool_calls": [
{
"id": call_id,
"function": {
"name": "shell",
"arguments": tool_call_arguments
}
}
]
},
"finish_reason": "tool_calls"
}
]
});
let sse = format!(
"data: {}\n\ndata: DONE\n\n",
serde_json::to_string(&tool_call)?
);
Ok(sse)
}
@@ -1,12 +1,12 @@
use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_protocol::mcp_protocol::ArchiveConversationParams;
use codex_protocol::mcp_protocol::ArchiveConversationResponse;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use tempfile::TempDir;
@@ -16,11 +16,6 @@ use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use codex_protocol::mcp_protocol::SendUserTurnParams;
use codex_protocol::mcp_protocol::SendUserTurnResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::create_final_assistant_message_sse_response;
use mcp_test_support::create_mock_chat_completions_server;
use mcp_test_support::create_shell_sse_response;
use mcp_test_support::to_response;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
@@ -1,6 +1,8 @@
use std::collections::HashMap;
use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
@@ -11,8 +13,6 @@ use codex_protocol::mcp_protocol::Profile;
use codex_protocol::mcp_protocol::SandboxSettings;
use codex_protocol::mcp_protocol::Tools;
use codex_protocol::mcp_protocol::UserSavedConfig;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
@@ -1,5 +1,9 @@
use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_chat_completions_server;
use app_test_support::to_response;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::InputItem;
@@ -7,10 +11,6 @@ use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::create_final_assistant_message_sse_response;
use mcp_test_support::create_mock_chat_completions_server;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
@@ -0,0 +1,104 @@
use app_test_support::McpProcess;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_sorts_and_includes_indices() {
// Prepare a temporary Codex home and a separate root with test files.
let codex_home = TempDir::new().expect("create temp codex home");
let root = TempDir::new().expect("create temp search root");
// Create files designed to have deterministic ordering for query "abc".
std::fs::write(root.path().join("abc"), "x").expect("write file abc");
std::fs::write(root.path().join("abcde"), "x").expect("write file abcx");
std::fs::write(root.path().join("abexy"), "x").expect("write file abcx");
std::fs::write(root.path().join("zzz.txt"), "x").expect("write file zzz");
// Start MCP server and initialize.
let mut mcp = McpProcess::new(codex_home.path()).await.expect("spawn mcp");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let root_path = root.path().to_string_lossy().to_string();
// Send fuzzyFileSearch request.
let request_id = mcp
.send_fuzzy_file_search_request("abe", vec![root_path.clone()], None)
.await
.expect("send fuzzyFileSearch");
// Read response and verify shape and ordering.
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("fuzzyFileSearch timeout")
.expect("fuzzyFileSearch resp");
let value = resp.result;
assert_eq!(
value,
json!({
"files": [
{ "root": root_path.clone(), "path": "abexy", "score": 88, "indices": [0, 1, 2] },
{ "root": root_path.clone(), "path": "abcde", "score": 74, "indices": [0, 1, 4] },
]
})
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_accepts_cancellation_token() {
let codex_home = TempDir::new().expect("create temp codex home");
let root = TempDir::new().expect("create temp search root");
std::fs::write(root.path().join("alpha.txt"), "contents").expect("write alpha");
let mut mcp = McpProcess::new(codex_home.path()).await.expect("spawn mcp");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let root_path = root.path().to_string_lossy().to_string();
let request_id = mcp
.send_fuzzy_file_search_request("alp", vec![root_path.clone()], None)
.await
.expect("send fuzzyFileSearch");
let request_id_2 = mcp
.send_fuzzy_file_search_request(
"alp",
vec![root_path.clone()],
Some(request_id.to_string()),
)
.await
.expect("send fuzzyFileSearch");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id_2)),
)
.await
.expect("fuzzyFileSearch timeout")
.expect("fuzzyFileSearch resp");
let files = resp
.result
.get("files")
.and_then(|value| value.as_array())
.cloned()
.expect("files array");
assert_eq!(files.len(), 1);
assert_eq!(files[0]["root"], root_path);
assert_eq!(files[0]["path"], "alpha.txt");
}
@@ -1,5 +1,5 @@
#![cfg(unix)]
// Support code lives in the `mcp_test_support` crate under tests/common.
// Support code lives in the `app_test_support` crate under tests/common.
use std::path::Path;
use codex_protocol::protocol::TurnAbortReason;
@@ -16,10 +16,10 @@ use mcp_types::RequestId;
use tempfile::TempDir;
use tokio::time::timeout;
use mcp_test_support::McpProcess;
use mcp_test_support::create_mock_chat_completions_server;
use mcp_test_support::create_shell_sse_response;
use mcp_test_support::to_response;
use app_test_support::McpProcess;
use app_test_support::create_mock_chat_completions_server;
use app_test_support::create_shell_sse_response;
use app_test_support::to_response;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
@@ -1,13 +1,15 @@
use std::fs;
use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_protocol::mcp_protocol::ListConversationsParams;
use codex_protocol::mcp_protocol::ListConversationsResponse;
use codex_protocol::mcp_protocol::NewConversationParams; // reused for overrides shape
use codex_protocol::mcp_protocol::ResumeConversationParams;
use codex_protocol::mcp_protocol::ResumeConversationResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use codex_protocol::mcp_protocol::ServerNotification;
use codex_protocol::mcp_protocol::SessionConfiguredNotification;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
@@ -111,23 +113,28 @@ async fn test_list_and_resume_conversations() {
.await
.expect("send resumeConversation");
// Expect a codex/event notification with msg.type == session_configured
// Expect a codex/event notification with msg.type == sessionConfigured
let notification: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event"),
mcp.read_stream_until_notification_message("sessionConfigured"),
)
.await
.expect("session_configured notification timeout")
.expect("session_configured notification");
// Basic shape assertion: ensure event type is session_configured
let msg_type = notification
.params
.as_ref()
.and_then(|p| p.get("msg"))
.and_then(|m| m.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("");
assert_eq!(msg_type, "session_configured");
.expect("sessionConfigured notification timeout")
.expect("sessionConfigured notification");
let session_configured: ServerNotification = notification
.try_into()
.expect("deserialize sessionConfigured notification");
// Basic shape assertion: ensure event type is sessionConfigured
let ServerNotification::SessionConfigured(SessionConfiguredNotification {
model,
rollout_path,
..
}) = session_configured
else {
unreachable!("expected sessionConfigured notification");
};
assert_eq!(model, "o3");
assert_eq!(items[0].path.clone(), rollout_path);
// Then the response for resumeConversation
let resume_resp: JSONRPCResponse = timeout(
@@ -8,8 +8,6 @@ use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::GetAuthStatusResponse;
use codex_protocol::mcp_protocol::LoginChatGptResponse;
use codex_protocol::mcp_protocol::LogoutChatGptResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use tempfile::TempDir;
+13
View File
@@ -0,0 +1,13 @@
mod archive_conversation;
mod auth;
mod codex_message_processor_flow;
mod config;
mod create_conversation;
mod fuzzy_file_search;
mod interrupt;
mod list_resume;
mod login;
mod send_message;
mod set_default_model;
mod user_agent;
mod user_info;
@@ -1,5 +1,9 @@
use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_chat_completions_server;
use app_test_support::to_response;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::ConversationId;
@@ -8,10 +12,6 @@ use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::create_final_assistant_message_sse_response;
use mcp_test_support::create_mock_chat_completions_server;
use mcp_test_support::to_response;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
@@ -1,10 +1,10 @@
use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_core::config::ConfigToml;
use codex_protocol::mcp_protocol::SetDefaultModelParams;
use codex_protocol::mcp_protocol::SetDefaultModelResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
@@ -1,6 +1,6 @@
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_protocol::mcp_protocol::GetUserAgentResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
@@ -35,7 +35,7 @@ async fn get_user_agent_returns_current_codex_user_agent() {
let os_info = os_info::get();
let user_agent = format!(
"codex_cli_rs/0.0.0 ({} {}; {}) {} (elicitation test; 0.0.0)",
"codex_cli_rs/0.0.0 ({} {}; {}) {} (codex-app-server-tests; 0.1.0)",
os_info.os_type(),
os_info.version(),
os_info.architecture().unwrap_or("unknown"),
@@ -0,0 +1,78 @@
use std::time::Duration;
use anyhow::Context;
use app_test_support::McpProcess;
use app_test_support::to_response;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_core::auth::AuthDotJson;
use codex_core::auth::get_auth_file;
use codex_core::auth::write_auth_json;
use codex_core::token_data::IdTokenInfo;
use codex_core::token_data::TokenData;
use codex_protocol::mcp_protocol::UserInfoResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_info_returns_email_from_auth_json() {
let codex_home = TempDir::new().expect("create tempdir");
let auth_path = get_auth_file(codex_home.path());
let mut id_token = IdTokenInfo::default();
id_token.email = Some("[email protected]".to_string());
id_token.raw_jwt = encode_id_token_with_email("[email protected]").expect("encode id token");
let auth = AuthDotJson {
openai_api_key: None,
tokens: Some(TokenData {
id_token,
access_token: "access".to_string(),
refresh_token: "refresh".to_string(),
account_id: None,
}),
last_refresh: None,
};
write_auth_json(&auth_path, &auth).expect("write auth.json");
let mut mcp = McpProcess::new(codex_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("initialize timeout")
.expect("initialize request");
let request_id = mcp.send_user_info_request().await.expect("send userInfo");
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("userInfo timeout")
.expect("userInfo response");
let received: UserInfoResponse = to_response(response).expect("deserialize userInfo response");
let expected = UserInfoResponse {
alleged_user_email: Some("[email protected]".to_string()),
};
assert_eq!(received, expected);
}
fn encode_id_token_with_email(email: &str) -> anyhow::Result<String> {
let header_b64 = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&json!({ "alg": "none", "typ": "JWT" }))
.context("serialize jwt header")?,
);
let payload =
serde_json::to_vec(&json!({ "email": email })).context("serialize jwt payload")?;
let payload_b64 = URL_SAFE_NO_PAD.encode(payload);
Ok(format!("{header_b64}.{payload_b64}.signature"))
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "codex-backend-client"
version = "0.0.0"
edition = "2024"
publish = false
[lib]
path = "src/lib.rs"
[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
codex-backend-openapi-models = { path = "../codex-backend-openapi-models" }
[dev-dependencies]
pretty_assertions = "1"
+244
View File
@@ -0,0 +1,244 @@
use crate::types::CodeTaskDetailsResponse;
use crate::types::PaginatedListTaskListItem;
use crate::types::TurnAttemptsSiblingTurnsResponse;
use anyhow::Result;
use reqwest::header::AUTHORIZATION;
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
use reqwest::header::USER_AGENT;
use serde::de::DeserializeOwned;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PathStyle {
/// /api/codex/…
CodexApi,
/// /wham/…
ChatGptApi,
}
impl PathStyle {
pub fn from_base_url(base_url: &str) -> Self {
if base_url.contains("/backend-api") {
PathStyle::ChatGptApi
} else {
PathStyle::CodexApi
}
}
}
#[derive(Clone, Debug)]
pub struct Client {
base_url: String,
http: reqwest::Client,
bearer_token: Option<String>,
user_agent: Option<HeaderValue>,
chatgpt_account_id: Option<String>,
path_style: PathStyle,
}
impl Client {
pub fn new(base_url: impl Into<String>) -> Result<Self> {
let mut base_url = base_url.into();
// Normalize common ChatGPT hostnames to include /backend-api so we hit the WHAM paths.
// Also trim trailing slashes for consistent URL building.
while base_url.ends_with('/') {
base_url.pop();
}
if (base_url.starts_with("https://chatgpt.com")
|| base_url.starts_with("https://chat.openai.com"))
&& !base_url.contains("/backend-api")
{
base_url = format!("{base_url}/backend-api");
}
let http = reqwest::Client::builder().build()?;
let path_style = PathStyle::from_base_url(&base_url);
Ok(Self {
base_url,
http,
bearer_token: None,
user_agent: None,
chatgpt_account_id: None,
path_style,
})
}
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
self.bearer_token = Some(token.into());
self
}
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
if let Ok(hv) = HeaderValue::from_str(&ua.into()) {
self.user_agent = Some(hv);
}
self
}
pub fn with_chatgpt_account_id(mut self, account_id: impl Into<String>) -> Self {
self.chatgpt_account_id = Some(account_id.into());
self
}
pub fn with_path_style(mut self, style: PathStyle) -> Self {
self.path_style = style;
self
}
fn headers(&self) -> HeaderMap {
let mut h = HeaderMap::new();
if let Some(ua) = &self.user_agent {
h.insert(USER_AGENT, ua.clone());
} else {
h.insert(USER_AGENT, HeaderValue::from_static("codex-cli"));
}
if let Some(token) = &self.bearer_token {
let value = format!("Bearer {token}");
if let Ok(hv) = HeaderValue::from_str(&value) {
h.insert(AUTHORIZATION, hv);
}
}
if let Some(acc) = &self.chatgpt_account_id
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
&& let Ok(hv) = HeaderValue::from_str(acc)
{
h.insert(name, hv);
}
h
}
async fn exec_request(
&self,
req: reqwest::RequestBuilder,
method: &str,
url: &str,
) -> Result<(String, String)> {
let res = req.send().await?;
let status = res.status();
let ct = res
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = res.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("{method} {url} failed: {status}; content-type={ct}; body={body}");
}
Ok((body, ct))
}
fn decode_json<T: DeserializeOwned>(&self, url: &str, ct: &str, body: &str) -> Result<T> {
match serde_json::from_str::<T>(body) {
Ok(v) => Ok(v),
Err(e) => {
anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}");
}
}
}
pub async fn list_tasks(
&self,
limit: Option<i32>,
task_filter: Option<&str>,
environment_id: Option<&str>,
) -> Result<PaginatedListTaskListItem> {
let url = match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/tasks/list", self.base_url),
};
let req = self.http.get(&url).headers(self.headers());
let req = if let Some(lim) = limit {
req.query(&[("limit", lim)])
} else {
req
};
let req = if let Some(tf) = task_filter {
req.query(&[("task_filter", tf)])
} else {
req
};
let req = if let Some(id) = environment_id {
req.query(&[("environment_id", id)])
} else {
req
};
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json::<PaginatedListTaskListItem>(&url, &ct, &body)
}
pub async fn get_task_details(&self, task_id: &str) -> Result<CodeTaskDetailsResponse> {
let (parsed, _body, _ct) = self.get_task_details_with_body(task_id).await?;
Ok(parsed)
}
pub async fn get_task_details_with_body(
&self,
task_id: &str,
) -> Result<(CodeTaskDetailsResponse, String, String)> {
let url = match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/tasks/{}", self.base_url, task_id),
PathStyle::ChatGptApi => format!("{}/wham/tasks/{}", self.base_url, task_id),
};
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
let parsed: CodeTaskDetailsResponse = self.decode_json(&url, &ct, &body)?;
Ok((parsed, body, ct))
}
pub async fn list_sibling_turns(
&self,
task_id: &str,
turn_id: &str,
) -> Result<TurnAttemptsSiblingTurnsResponse> {
let url = match self.path_style {
PathStyle::CodexApi => format!(
"{}/api/codex/tasks/{}/turns/{}/sibling_turns",
self.base_url, task_id, turn_id
),
PathStyle::ChatGptApi => format!(
"{}/wham/tasks/{}/turns/{}/sibling_turns",
self.base_url, task_id, turn_id
),
};
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json::<TurnAttemptsSiblingTurnsResponse>(&url, &ct, &body)
}
/// Create a new task (user turn) by POSTing to the appropriate backend path
/// based on `path_style`. Returns the created task id.
pub async fn create_task(&self, request_body: serde_json::Value) -> Result<String> {
let url = match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/tasks", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/tasks", self.base_url),
};
let req = self
.http
.post(&url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&request_body);
let (body, ct) = self.exec_request(req, "POST", &url).await?;
// Extract id from JSON: prefer `task.id`; fallback to top-level `id` when present.
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(v) => {
if let Some(id) = v
.get("task")
.and_then(|t| t.get("id"))
.and_then(|s| s.as_str())
{
Ok(id.to_string())
} else if let Some(id) = v.get("id").and_then(|s| s.as_str()) {
Ok(id.to_string())
} else {
anyhow::bail!(
"POST {url} succeeded but no task id found; content-type={ct}; body={body}"
);
}
}
Err(e) => anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"),
}
}
}
+9
View File
@@ -0,0 +1,9 @@
mod client;
pub mod types;
pub use client::Client;
pub use types::CodeTaskDetailsResponse;
pub use types::CodeTaskDetailsResponseExt;
pub use types::PaginatedListTaskListItem;
pub use types::TaskListItem;
pub use types::TurnAttemptsSiblingTurnsResponse;
+369
View File
@@ -0,0 +1,369 @@
pub use codex_backend_openapi_models::models::PaginatedListTaskListItem;
pub use codex_backend_openapi_models::models::TaskListItem;
use serde::Deserialize;
use serde::de::Deserializer;
use serde_json::Value;
use std::collections::HashMap;
/// Hand-rolled models for the Cloud Tasks task-details response.
/// The generated OpenAPI models are pretty bad. This is a half-step
/// towards hand-rolling them.
#[derive(Clone, Debug, Deserialize)]
pub struct CodeTaskDetailsResponse {
#[serde(default)]
pub current_user_turn: Option<Turn>,
#[serde(default)]
pub current_assistant_turn: Option<Turn>,
#[serde(default)]
pub current_diff_task_turn: Option<Turn>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Turn {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub attempt_placement: Option<i64>,
#[serde(default, rename = "turn_status")]
pub turn_status: Option<String>,
#[serde(default, deserialize_with = "deserialize_vec")]
pub sibling_turn_ids: Vec<String>,
#[serde(default, deserialize_with = "deserialize_vec")]
pub input_items: Vec<TurnItem>,
#[serde(default, deserialize_with = "deserialize_vec")]
pub output_items: Vec<TurnItem>,
#[serde(default)]
pub worklog: Option<Worklog>,
#[serde(default)]
pub error: Option<TurnError>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TurnItem {
#[serde(rename = "type", default)]
pub kind: String,
#[serde(default)]
pub role: Option<String>,
#[serde(default, deserialize_with = "deserialize_vec")]
pub content: Vec<ContentFragment>,
#[serde(default)]
pub diff: Option<String>,
#[serde(default)]
pub output_diff: Option<DiffPayload>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum ContentFragment {
Structured(StructuredContent),
Text(String),
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct StructuredContent {
#[serde(rename = "content_type", default)]
pub content_type: Option<String>,
#[serde(default)]
pub text: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct DiffPayload {
#[serde(default)]
pub diff: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Worklog {
#[serde(default, deserialize_with = "deserialize_vec")]
pub messages: Vec<WorklogMessage>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct WorklogMessage {
#[serde(default)]
pub author: Option<Author>,
#[serde(default)]
pub content: Option<WorklogContent>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Author {
#[serde(default)]
pub role: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct WorklogContent {
#[serde(default)]
pub parts: Vec<ContentFragment>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TurnError {
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub message: Option<String>,
}
impl ContentFragment {
fn text(&self) -> Option<&str> {
match self {
ContentFragment::Structured(inner) => {
if inner
.content_type
.as_deref()
.map(|ct| ct.eq_ignore_ascii_case("text"))
.unwrap_or(false)
{
inner.text.as_deref().filter(|s| !s.is_empty())
} else {
None
}
}
ContentFragment::Text(raw) => {
if raw.trim().is_empty() {
None
} else {
Some(raw.as_str())
}
}
}
}
}
impl TurnItem {
fn text_values(&self) -> Vec<String> {
self.content
.iter()
.filter_map(|fragment| fragment.text().map(str::to_string))
.collect()
}
fn diff_text(&self) -> Option<String> {
if self.kind == "output_diff" {
if let Some(diff) = &self.diff
&& !diff.is_empty()
{
return Some(diff.clone());
}
} else if self.kind == "pr"
&& let Some(payload) = &self.output_diff
&& let Some(diff) = &payload.diff
&& !diff.is_empty()
{
return Some(diff.clone());
}
None
}
}
impl Turn {
fn unified_diff(&self) -> Option<String> {
self.output_items.iter().find_map(TurnItem::diff_text)
}
fn message_texts(&self) -> Vec<String> {
let mut out: Vec<String> = self
.output_items
.iter()
.filter(|item| item.kind == "message")
.flat_map(TurnItem::text_values)
.collect();
if let Some(log) = &self.worklog {
for message in &log.messages {
if message.is_assistant() {
out.extend(message.text_values());
}
}
}
out
}
fn user_prompt(&self) -> Option<String> {
let parts: Vec<String> = self
.input_items
.iter()
.filter(|item| item.kind == "message")
.filter(|item| {
item.role
.as_deref()
.map(|r| r.eq_ignore_ascii_case("user"))
.unwrap_or(true)
})
.flat_map(TurnItem::text_values)
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join(
"
",
))
}
}
fn error_summary(&self) -> Option<String> {
self.error.as_ref().and_then(TurnError::summary)
}
}
impl WorklogMessage {
fn is_assistant(&self) -> bool {
self.author
.as_ref()
.and_then(|a| a.role.as_deref())
.map(|role| role.eq_ignore_ascii_case("assistant"))
.unwrap_or(false)
}
fn text_values(&self) -> Vec<String> {
self.content
.as_ref()
.map(|content| {
content
.parts
.iter()
.filter_map(|fragment| fragment.text().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
}
impl TurnError {
fn summary(&self) -> Option<String> {
let code = self.code.as_deref().unwrap_or("");
let message = self.message.as_deref().unwrap_or("");
match (code.is_empty(), message.is_empty()) {
(true, true) => None,
(false, true) => Some(code.to_string()),
(true, false) => Some(message.to_string()),
(false, false) => Some(format!("{code}: {message}")),
}
}
}
pub trait CodeTaskDetailsResponseExt {
/// Attempt to extract a unified diff string from the assistant or diff turn.
fn unified_diff(&self) -> Option<String>;
/// Extract assistant text output messages (no diff) from current turns.
fn assistant_text_messages(&self) -> Vec<String>;
/// Extract the user's prompt text from the current user turn, when present.
fn user_text_prompt(&self) -> Option<String>;
/// Extract an assistant error message (if the turn failed and provided one).
fn assistant_error_message(&self) -> Option<String>;
}
impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse {
fn unified_diff(&self) -> Option<String> {
[
self.current_diff_task_turn.as_ref(),
self.current_assistant_turn.as_ref(),
]
.into_iter()
.flatten()
.find_map(Turn::unified_diff)
}
fn assistant_text_messages(&self) -> Vec<String> {
let mut out = Vec::new();
for turn in [
self.current_diff_task_turn.as_ref(),
self.current_assistant_turn.as_ref(),
]
.into_iter()
.flatten()
{
out.extend(turn.message_texts());
}
out
}
fn user_text_prompt(&self) -> Option<String> {
self.current_user_turn.as_ref().and_then(Turn::user_prompt)
}
fn assistant_error_message(&self) -> Option<String> {
self.current_assistant_turn
.as_ref()
.and_then(Turn::error_summary)
}
}
fn deserialize_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<Vec<T>>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
}
#[derive(Clone, Debug, Deserialize)]
pub struct TurnAttemptsSiblingTurnsResponse {
#[serde(default)]
pub sibling_turns: Vec<HashMap<String, Value>>,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn fixture(name: &str) -> CodeTaskDetailsResponse {
let json = match name {
"diff" => include_str!("../tests/fixtures/task_details_with_diff.json"),
"error" => include_str!("../tests/fixtures/task_details_with_error.json"),
other => panic!("unknown fixture {other}"),
};
serde_json::from_str(json).expect("fixture should deserialize")
}
#[test]
fn unified_diff_prefers_current_diff_task_turn() {
let details = fixture("diff");
let diff = details.unified_diff().expect("diff present");
assert!(diff.contains("diff --git"));
}
#[test]
fn unified_diff_falls_back_to_pr_output_diff() {
let details = fixture("error");
let diff = details.unified_diff().expect("diff from pr output");
assert!(diff.contains("lib.rs"));
}
#[test]
fn assistant_text_messages_extracts_text_content() {
let details = fixture("diff");
let messages = details.assistant_text_messages();
assert_eq!(messages, vec!["Assistant response".to_string()]);
}
#[test]
fn user_text_prompt_joins_parts_with_spacing() {
let details = fixture("diff");
let prompt = details.user_text_prompt().expect("prompt present");
assert_eq!(
prompt,
"First line
Second line"
);
}
#[test]
fn assistant_error_message_combines_code_and_message() {
let details = fixture("error");
let msg = details
.assistant_error_message()
.expect("error should be present");
assert_eq!(msg, "APPLY_FAILED: Patch could not be applied");
}
}
@@ -0,0 +1,38 @@
{
"task": {
"id": "task_123",
"title": "Refactor cloud task client",
"archived": false,
"external_pull_requests": []
},
"current_user_turn": {
"input_items": [
{
"type": "message",
"role": "user",
"content": [
{ "content_type": "text", "text": "First line" },
{ "content_type": "text", "text": "Second line" }
]
}
]
},
"current_assistant_turn": {
"output_items": [
{
"type": "message",
"content": [
{ "content_type": "text", "text": "Assistant response" }
]
}
]
},
"current_diff_task_turn": {
"output_items": [
{
"type": "output_diff",
"diff": "diff --git a/src/main.rs b/src/main.rs\n+fn main() { println!(\"hi\"); }\n"
}
]
}
}
@@ -0,0 +1,22 @@
{
"task": {
"id": "task_456",
"title": "Investigate failure",
"archived": false,
"external_pull_requests": []
},
"current_assistant_turn": {
"output_items": [
{
"type": "pr",
"output_diff": {
"diff": "diff --git a/lib.rs b/lib.rs\n+pub fn hello() {}\n"
}
}
],
"error": {
"code": "APPLY_FAILED",
"message": "Patch could not be applied"
}
}
}
+1
View File
@@ -15,6 +15,7 @@ codex-protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
codex-git-apply = { path = "../git-apply" }
[dev-dependencies]
tempfile = { workspace = true }
+15 -37
View File
@@ -56,46 +56,24 @@ pub async fn apply_diff_from_task(
}
async fn apply_diff(diff: &str, cwd: Option<PathBuf>) -> anyhow::Result<()> {
let mut cmd = tokio::process::Command::new("git");
if let Some(cwd) = cwd {
cmd.current_dir(cwd);
}
let toplevel_output = cmd
.args(vec!["rev-parse", "--show-toplevel"])
.output()
.await?;
if !toplevel_output.status.success() {
anyhow::bail!("apply must be run from a git repository.");
}
let repo_root = String::from_utf8(toplevel_output.stdout)?
.trim()
.to_string();
let mut git_apply_cmd = tokio::process::Command::new("git")
.args(vec!["apply", "--3way"])
.current_dir(&repo_root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
if let Some(mut stdin) = git_apply_cmd.stdin.take() {
tokio::io::AsyncWriteExt::write_all(&mut stdin, diff.as_bytes()).await?;
drop(stdin);
}
let output = git_apply_cmd.wait_with_output().await?;
if !output.status.success() {
let cwd = cwd.unwrap_or(std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()));
let req = codex_git_apply::ApplyGitRequest {
cwd,
diff: diff.to_string(),
revert: false,
preflight: false,
};
let res = codex_git_apply::apply_git_patch(&req)?;
if res.exit_code != 0 {
anyhow::bail!(
"Git apply failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
"Git apply failed (applied={}, skipped={}, conflicts={})\nstdout:\n{}\nstderr:\n{}",
res.applied_paths.len(),
res.skipped_paths.len(),
res.conflicted_paths.len(),
res.stdout,
res.stderr
);
}
println!("Successfully applied diff");
Ok(())
}
+1 -1
View File
@@ -43,6 +43,6 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("Request failed with status {}: {}", status, body)
anyhow::bail!("Request failed with status {status}: {body}")
}
}
+2
View File
@@ -18,6 +18,7 @@ workspace = true
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
clap_complete = { workspace = true }
codex-app-server = { workspace = true }
codex-arg0 = { workspace = true }
codex-chatgpt = { workspace = true }
codex-common = { workspace = true, features = ["cli"] }
@@ -30,6 +31,7 @@ codex-protocol = { workspace = true }
codex-protocol-ts = { workspace = true }
codex-tui = { workspace = true }
codex-version = { path = "../codex-version" }
codex-cloud-tasks = { workspace = true }
ctor = { workspace = true }
futures = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "json"] }
+2 -1
View File
@@ -120,7 +120,7 @@ async fn run_llm_request(
prompt.text_format = Some(text_format);
// Auth + provider
let auth_mgr = AuthManager::shared(
let auth_mgr = AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
AuthMode::ApiKey,
config.responses_originator_header.clone(),
@@ -129,6 +129,7 @@ async fn run_llm_request(
let client = ModelClient::new(
std::sync::Arc::new(config.clone()),
Some(auth_mgr),
None,
provider,
config.model_reasoning_effort,
config.model_reasoning_summary,
+28
View File
@@ -7,6 +7,7 @@ use codex_core::auth::logout;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_login::ServerOptions;
use codex_login::run_device_code_login;
use codex_login::run_login_server;
use codex_protocol::mcp_protocol::AuthMode;
use std::env;
@@ -62,6 +63,33 @@ pub async fn run_login_with_api_key(
}
}
/// Login using the OAuth device code flow.
pub async fn run_login_with_device_code(
cli_config_overrides: CliConfigOverrides,
issuer_base_url: Option<String>,
client_id: Option<String>,
) -> ! {
let config = load_config_or_exit(cli_config_overrides);
let mut opts = ServerOptions::new(
config.codex_home,
client_id.unwrap_or(CLIENT_ID.to_string()),
config.responses_originator_header.clone(),
);
if let Some(iss) = issuer_base_url {
opts.issuer = iss;
}
match run_device_code_login(opts).await {
Ok(()) => {
eprintln!("Successfully logged in");
std::process::exit(0);
}
Err(e) => {
eprintln!("Error logging in with device code: {e}");
std::process::exit(1);
}
}
}
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
+47 -2
View File
@@ -12,6 +12,7 @@ use codex_cli::SeatbeltCommand;
use codex_cli::login::run_login_status;
use codex_cli::login::run_login_with_api_key;
use codex_cli::login::run_login_with_chatgpt;
use codex_cli::login::run_login_with_device_code;
use codex_cli::login::run_logout;
use codex_cli::proto;
mod llm;
@@ -19,6 +20,7 @@ use llm::{LlmCli, run_llm};
use codex_common::CliConfigOverrides;
use codex_core::find_conversation_path_by_id_str;
use codex_core::RolloutRecorder;
use codex_cloud_tasks::Cli as CloudTasksCli;
use codex_exec::Cli as ExecCli;
use codex_tui::Cli as TuiCli;
use codex_tui::ExitSummary;
@@ -97,6 +99,12 @@ enum Subcommand {
#[clap(visible_alias = "acp")]
Mcp(McpCli),
/// [experimental] Run the Codex MCP server (stdio transport).
McpServer,
/// [experimental] Run the app server.
AppServer,
/// Run the Protocol stream via stdin/stdout
#[clap(visible_alias = "p")]
Proto(ProtoCli),
@@ -121,6 +129,9 @@ enum Subcommand {
/// Internal: generate TypeScript protocol bindings.
#[clap(hide = true)]
GenerateTs(GenerateTsCommand),
/// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally.
#[clap(name = "cloud", alias = "cloud-tasks")]
Cloud(CloudTasksCli),
/// Diagnose PATH, binary collisions, and versions.
Doctor,
@@ -177,6 +188,20 @@ struct LoginCommand {
#[arg(long = "api-key", value_name = "API_KEY")]
api_key: Option<String>,
/// EXPERIMENTAL: Use device code flow (not yet supported)
/// This feature is experimental and may changed in future releases.
#[arg(long = "experimental_use-device-code", hide = true)]
use_device_code: bool,
/// EXPERIMENTAL: Use custom OAuth issuer base URL (advanced)
/// Override the OAuth issuer base URL (advanced)
#[arg(long = "experimental_issuer", value_name = "URL", hide = true)]
issuer_base_url: Option<String>,
/// EXPERIMENTAL: Use custom OAuth client ID (advanced)
#[arg(long = "experimental_client-id", value_name = "CLIENT_ID", hide = true)]
client_id: Option<String>,
#[command(subcommand)]
action: Option<LoginSubcommand>,
}
@@ -276,10 +301,16 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
);
codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?;
}
Some(Subcommand::McpServer) => {
codex_mcp_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?;
}
Some(Subcommand::Mcp(mut mcp_cli)) => {
// Propagate any root-level config overrides (e.g. `-c key=value`).
prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone());
mcp_cli.run(codex_linux_sandbox_exe).await?;
mcp_cli.run().await?;
}
Some(Subcommand::AppServer) => {
codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?;
}
Some(Subcommand::Resume(ResumeCommand {
session_id,
@@ -322,7 +353,14 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
run_login_status(login_cli.config_overrides).await;
}
None => {
if let Some(api_key) = login_cli.api_key {
if login_cli.use_device_code {
run_login_with_device_code(
login_cli.config_overrides,
login_cli.issuer_base_url,
login_cli.client_id,
)
.await;
} else if let Some(api_key) = login_cli.api_key {
run_login_with_api_key(login_cli.config_overrides, api_key).await;
} else {
run_login_with_chatgpt(login_cli.config_overrides).await;
@@ -347,6 +385,13 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
Some(Subcommand::Completion(completion_cli)) => {
print_completion(completion_cli);
}
Some(Subcommand::Cloud(mut cloud_cli)) => {
prepend_config_flags(
&mut cloud_cli.config_overrides,
root_config_overrides.clone(),
);
codex_cloud_tasks::run_main(cloud_cli, codex_linux_sandbox_exe).await?;
}
Some(Subcommand::Debug(debug_args)) => match debug_args.cmd {
DebugCommand::Seatbelt(mut seatbelt_cli) => {
prepend_config_flags(
+3 -11
View File
@@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
@@ -28,14 +27,11 @@ pub struct McpCli {
pub config_overrides: CliConfigOverrides,
#[command(subcommand)]
pub cmd: Option<McpSubcommand>,
pub subcommand: McpSubcommand,
}
#[derive(Debug, clap::Subcommand)]
pub enum McpSubcommand {
/// [experimental] Run the Codex MCP server (stdio transport).
Serve,
/// [experimental] List configured MCP servers.
List(ListArgs),
@@ -87,17 +83,13 @@ pub struct RemoveArgs {
}
impl McpCli {
pub async fn run(self, codex_linux_sandbox_exe: Option<PathBuf>) -> Result<()> {
pub async fn run(self) -> Result<()> {
let McpCli {
config_overrides,
cmd,
subcommand,
} = self;
let subcommand = cmd.unwrap_or(McpSubcommand::Serve);
match subcommand {
McpSubcommand::Serve => {
codex_mcp_server::run_main(codex_linux_sandbox_exe, config_overrides).await?;
}
McpSubcommand::List(args) => {
run_list(&config_overrides, args)?;
}
+7 -5
View File
@@ -37,11 +37,13 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
// Use conversation_manager API to start a conversation
let conversation_manager = ConversationManager::new(AuthManager::shared(
config.codex_home.clone(),
codex_login::AuthMode::ApiKey,
config.responses_originator_header.clone(),
));
let conversation_manager = ConversationManager::new(
AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
codex_login::AuthMode::ApiKey,
config.responses_originator_header.clone(),
),
);
let NewConversation {
conversation_id: _,
conversation,
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "codex-cloud-tasks-client"
version = { workspace = true }
edition = "2024"
[lib]
name = "codex_cloud_tasks_client"
path = "src/lib.rs"
[lints]
workspace = true
[features]
default = ["online"]
online = ["dep:codex-backend-client"]
mock = []
[dependencies]
anyhow = "1"
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
diffy = "0.4.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2.0.12"
codex-backend-client = { path = "../backend-client", optional = true }
codex-git-apply = { path = "../git-apply" }
+158
View File
@@ -0,0 +1,158 @@
use chrono::DateTime;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
pub type Result<T> = std::result::Result<T, CloudTaskError>;
#[derive(Debug, thiserror::Error)]
pub enum CloudTaskError {
#[error("unimplemented: {0}")]
Unimplemented(&'static str),
#[error("http error: {0}")]
Http(String),
#[error("io error: {0}")]
Io(String),
#[error("{0}")]
Msg(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TaskId(pub String);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TaskStatus {
Pending,
Ready,
Applied,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskSummary {
pub id: TaskId,
pub title: String,
pub status: TaskStatus,
pub updated_at: DateTime<Utc>,
/// Backend environment identifier (when available)
pub environment_id: Option<String>,
/// Human-friendly environment label (when available)
pub environment_label: Option<String>,
pub summary: DiffSummary,
/// True when the backend reports this task as a code review.
#[serde(default)]
pub is_review: bool,
/// Number of assistant attempts (best-of-N), when reported by the backend.
#[serde(default)]
pub attempt_total: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum AttemptStatus {
Pending,
InProgress,
Completed,
Failed,
Cancelled,
#[default]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TurnAttempt {
pub turn_id: String,
pub attempt_placement: Option<i64>,
pub created_at: Option<DateTime<Utc>>,
pub status: AttemptStatus,
pub diff: Option<String>,
pub messages: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApplyStatus {
Success,
Partial,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApplyOutcome {
pub applied: bool,
pub status: ApplyStatus,
pub message: String,
#[serde(default)]
pub skipped_paths: Vec<String>,
#[serde(default)]
pub conflict_paths: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreatedTask {
pub id: TaskId,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct DiffSummary {
pub files_changed: usize,
pub lines_added: usize,
pub lines_removed: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaskText {
pub prompt: Option<String>,
pub messages: Vec<String>,
pub turn_id: Option<String>,
pub sibling_turn_ids: Vec<String>,
pub attempt_placement: Option<i64>,
pub attempt_status: AttemptStatus,
}
impl Default for TaskText {
fn default() -> Self {
Self {
prompt: None,
messages: Vec::new(),
turn_id: None,
sibling_turn_ids: Vec::new(),
attempt_placement: None,
attempt_status: AttemptStatus::Unknown,
}
}
}
#[async_trait::async_trait]
pub trait CloudBackend: Send + Sync {
async fn list_tasks(&self, env: Option<&str>) -> Result<Vec<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>>;
/// Return the creating prompt and assistant messages (when available).
async fn get_task_text(&self, id: TaskId) -> Result<TaskText>;
/// Return any sibling attempts (best-of-N) for the given assistant turn.
async fn list_sibling_attempts(
&self,
task: TaskId,
turn_id: String,
) -> Result<Vec<TurnAttempt>>;
/// Dry-run apply (preflight) that validates whether the patch would apply cleanly.
/// Never modifies the working tree. When `diff_override` is supplied, the provided diff is
/// used instead of re-fetching the task details so callers can apply alternate attempts.
async fn apply_task_preflight(
&self,
id: TaskId,
diff_override: Option<String>,
) -> Result<ApplyOutcome>;
async fn apply_task(&self, id: TaskId, diff_override: Option<String>) -> Result<ApplyOutcome>;
async fn create_task(
&self,
env_id: &str,
prompt: &str,
git_ref: &str,
qa_mode: bool,
best_of_n: usize,
) -> Result<CreatedTask>;
}
+769
View File
@@ -0,0 +1,769 @@
use crate::ApplyOutcome;
use crate::ApplyStatus;
use crate::AttemptStatus;
use crate::CloudBackend;
use crate::CloudTaskError;
use crate::DiffSummary;
use crate::Result;
use crate::TaskId;
use crate::TaskStatus;
use crate::TaskSummary;
use crate::TurnAttempt;
use crate::api::TaskText;
use chrono::DateTime;
use chrono::Utc;
use codex_backend_client as backend;
use codex_backend_client::CodeTaskDetailsResponseExt;
#[derive(Clone)]
pub struct HttpClient {
pub base_url: String,
backend: backend::Client,
}
impl HttpClient {
pub fn new(base_url: impl Into<String>) -> anyhow::Result<Self> {
let base_url = base_url.into();
let backend = backend::Client::new(base_url.clone())?;
Ok(Self { base_url, backend })
}
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
self.backend = self.backend.clone().with_bearer_token(token);
self
}
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
self.backend = self.backend.clone().with_user_agent(ua);
self
}
pub fn with_chatgpt_account_id(mut self, account_id: impl Into<String>) -> Self {
self.backend = self.backend.clone().with_chatgpt_account_id(account_id);
self
}
fn tasks_api(&self) -> api::Tasks<'_> {
api::Tasks::new(self)
}
fn attempts_api(&self) -> api::Attempts<'_> {
api::Attempts::new(self)
}
fn apply_api(&self) -> api::Apply<'_> {
api::Apply::new(self)
}
}
#[async_trait::async_trait]
impl CloudBackend for HttpClient {
async fn list_tasks(&self, env: Option<&str>) -> Result<Vec<TaskSummary>> {
self.tasks_api().list(env).await
}
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>> {
self.tasks_api().diff(id).await
}
async fn get_task_messages(&self, id: TaskId) -> Result<Vec<String>> {
self.tasks_api().messages(id).await
}
async fn get_task_text(&self, id: TaskId) -> Result<TaskText> {
self.tasks_api().task_text(id).await
}
async fn list_sibling_attempts(
&self,
task: TaskId,
turn_id: String,
) -> Result<Vec<TurnAttempt>> {
self.attempts_api().list(task, turn_id).await
}
async fn apply_task(&self, id: TaskId, diff_override: Option<String>) -> Result<ApplyOutcome> {
self.apply_api().run(id, diff_override, false).await
}
async fn apply_task_preflight(
&self,
id: TaskId,
diff_override: Option<String>,
) -> Result<ApplyOutcome> {
self.apply_api().run(id, diff_override, true).await
}
async fn create_task(
&self,
env_id: &str,
prompt: &str,
git_ref: &str,
qa_mode: bool,
best_of_n: usize,
) -> Result<crate::CreatedTask> {
self.tasks_api()
.create(env_id, prompt, git_ref, qa_mode, best_of_n)
.await
}
}
mod api {
use super::*;
use serde_json::Value;
use std::cmp::Ordering;
use std::collections::HashMap;
pub(crate) struct Tasks<'a> {
base_url: &'a str,
backend: &'a backend::Client,
}
impl<'a> Tasks<'a> {
pub(crate) fn new(client: &'a HttpClient) -> Self {
Self {
base_url: &client.base_url,
backend: &client.backend,
}
}
pub(crate) async fn list(&self, env: Option<&str>) -> Result<Vec<TaskSummary>> {
let resp = self
.backend
.list_tasks(Some(20), Some("current"), env)
.await
.map_err(|e| CloudTaskError::Http(format!("list_tasks failed: {e}")))?;
let tasks: Vec<TaskSummary> = resp
.items
.into_iter()
.map(map_task_list_item_to_summary)
.collect();
append_error_log(&format!(
"http.list_tasks: env={} items={}",
env.unwrap_or("<all>"),
tasks.len()
));
Ok(tasks)
}
pub(crate) async fn diff(&self, id: TaskId) -> Result<Option<String>> {
let (details, body, ct) = self
.details_with_body(&id.0)
.await
.map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?;
if let Some(diff) = details.unified_diff() {
return Ok(Some(diff));
}
let _ = (body, ct);
Ok(None)
}
pub(crate) async fn messages(&self, id: TaskId) -> Result<Vec<String>> {
let (details, body, ct) = self
.details_with_body(&id.0)
.await
.map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?;
let mut msgs = details.assistant_text_messages();
if msgs.is_empty() {
msgs.extend(extract_assistant_messages_from_body(&body));
}
if !msgs.is_empty() {
return Ok(msgs);
}
if let Some(err) = details.assistant_error_message() {
return Ok(vec![format!("Task failed: {err}")]);
}
let url = match details_path(self.base_url, &id.0) {
Some(url) => url,
None => format!("{}/api/codex/tasks/{}", self.base_url, id.0),
};
Err(CloudTaskError::Http(format!(
"No assistant text messages in response. GET {url}; content-type={ct}; body={body}"
)))
}
pub(crate) async fn task_text(&self, id: TaskId) -> Result<TaskText> {
let (details, body, _ct) = self
.details_with_body(&id.0)
.await
.map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?;
let prompt = details.user_text_prompt();
let mut messages = details.assistant_text_messages();
if messages.is_empty() {
messages.extend(extract_assistant_messages_from_body(&body));
}
let assistant_turn = details.current_assistant_turn.as_ref();
let turn_id = assistant_turn.and_then(|turn| turn.id.clone());
let sibling_turn_ids = assistant_turn
.map(|turn| turn.sibling_turn_ids.clone())
.unwrap_or_default();
let attempt_placement = assistant_turn.and_then(|turn| turn.attempt_placement);
let attempt_status = attempt_status_from_str(
assistant_turn.and_then(|turn| turn.turn_status.as_deref()),
);
Ok(TaskText {
prompt,
messages,
turn_id,
sibling_turn_ids,
attempt_placement,
attempt_status,
})
}
pub(crate) async fn create(
&self,
env_id: &str,
prompt: &str,
git_ref: &str,
qa_mode: bool,
best_of_n: usize,
) -> Result<crate::CreatedTask> {
let mut input_items: Vec<serde_json::Value> = Vec::new();
input_items.push(serde_json::json!({
"type": "message",
"role": "user",
"content": [{ "content_type": "text", "text": prompt }]
}));
if let Ok(diff) = std::env::var("CODEX_STARTING_DIFF")
&& !diff.is_empty()
{
input_items.push(serde_json::json!({
"type": "pre_apply_patch",
"output_diff": { "diff": diff }
}));
}
let mut request_body = serde_json::json!({
"new_task": {
"environment_id": env_id,
"branch": git_ref,
"run_environment_in_qa_mode": qa_mode,
},
"input_items": input_items,
});
if best_of_n > 1
&& let Some(obj) = request_body.as_object_mut()
{
obj.insert(
"metadata".to_string(),
serde_json::json!({ "best_of_n": best_of_n }),
);
}
match self.backend.create_task(request_body).await {
Ok(id) => {
append_error_log(&format!(
"new_task: created id={id} env={} prompt_chars={}",
env_id,
prompt.chars().count()
));
Ok(crate::CreatedTask { id: TaskId(id) })
}
Err(e) => {
append_error_log(&format!(
"new_task: create failed env={} prompt_chars={}: {}",
env_id,
prompt.chars().count(),
e
));
Err(CloudTaskError::Http(format!("create_task failed: {e}")))
}
}
}
async fn details_with_body(
&self,
id: &str,
) -> anyhow::Result<(backend::CodeTaskDetailsResponse, String, String)> {
let (parsed, body, ct) = self.backend.get_task_details_with_body(id).await?;
Ok((parsed, body, ct))
}
}
pub(crate) struct Attempts<'a> {
backend: &'a backend::Client,
}
impl<'a> Attempts<'a> {
pub(crate) fn new(client: &'a HttpClient) -> Self {
Self {
backend: &client.backend,
}
}
pub(crate) async fn list(&self, task: TaskId, turn_id: String) -> Result<Vec<TurnAttempt>> {
let resp = self
.backend
.list_sibling_turns(&task.0, &turn_id)
.await
.map_err(|e| CloudTaskError::Http(format!("list_sibling_turns failed: {e}")))?;
let mut attempts: Vec<TurnAttempt> = resp
.sibling_turns
.iter()
.filter_map(turn_attempt_from_map)
.collect();
attempts.sort_by(compare_attempts);
Ok(attempts)
}
}
pub(crate) struct Apply<'a> {
backend: &'a backend::Client,
}
impl<'a> Apply<'a> {
pub(crate) fn new(client: &'a HttpClient) -> Self {
Self {
backend: &client.backend,
}
}
pub(crate) async fn run(
&self,
task_id: TaskId,
diff_override: Option<String>,
preflight: bool,
) -> Result<ApplyOutcome> {
let id = task_id.0.clone();
let diff = match diff_override {
Some(diff) => diff,
None => {
let details = self.backend.get_task_details(&id).await.map_err(|e| {
CloudTaskError::Http(format!("get_task_details failed: {e}"))
})?;
details.unified_diff().ok_or_else(|| {
CloudTaskError::Msg(format!("No diff available for task {id}"))
})?
}
};
if !is_unified_diff(&diff) {
let summary = summarize_patch_for_logging(&diff);
let mode = if preflight { "preflight" } else { "apply" };
append_error_log(&format!(
"apply_error: id={id} mode={mode} format=non-unified; {summary}"
));
return Ok(ApplyOutcome {
applied: false,
status: ApplyStatus::Error,
message: "Expected unified git diff; backend returned an incompatible format."
.to_string(),
skipped_paths: Vec::new(),
conflict_paths: Vec::new(),
});
}
let req = codex_git_apply::ApplyGitRequest {
cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()),
diff: diff.clone(),
revert: false,
preflight,
};
let r = codex_git_apply::apply_git_patch(&req)
.map_err(|e| CloudTaskError::Io(format!("git apply failed to run: {e}")))?;
let status = if r.exit_code == 0 {
ApplyStatus::Success
} else if !r.applied_paths.is_empty() || !r.conflicted_paths.is_empty() {
ApplyStatus::Partial
} else {
ApplyStatus::Error
};
let applied = matches!(status, ApplyStatus::Success) && !preflight;
let message = if preflight {
match status {
ApplyStatus::Success => {
format!("Preflight passed for task {id} (applies cleanly)")
}
ApplyStatus::Partial => format!(
"Preflight: patch does not fully apply for task {id} (applied={}, skipped={}, conflicts={})",
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len()
),
ApplyStatus::Error => format!(
"Preflight failed for task {id} (applied={}, skipped={}, conflicts={})",
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len()
),
}
} else {
match status {
ApplyStatus::Success => format!(
"Applied task {id} locally ({} files)",
r.applied_paths.len()
),
ApplyStatus::Partial => format!(
"Apply partially succeeded for task {id} (applied={}, skipped={}, conflicts={})",
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len()
),
ApplyStatus::Error => format!(
"Apply failed for task {id} (applied={}, skipped={}, conflicts={})",
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len()
),
}
};
if matches!(status, ApplyStatus::Partial | ApplyStatus::Error)
|| (preflight && !matches!(status, ApplyStatus::Success))
{
let mut log = String::new();
let summary = summarize_patch_for_logging(&diff);
let mode = if preflight { "preflight" } else { "apply" };
use std::fmt::Write as _;
let _ = writeln!(
&mut log,
"apply_result: mode={} id={} status={:?} applied={} skipped={} conflicts={} cmd={}",
mode,
id,
status,
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len(),
r.cmd_for_log
);
let _ = writeln!(
&mut log,
"stdout_tail=\n{}\nstderr_tail=\n{}",
tail(&r.stdout, 2000),
tail(&r.stderr, 2000)
);
let _ = writeln!(&mut log, "{summary}");
let _ = writeln!(
&mut log,
"----- PATCH BEGIN -----\n{diff}\n----- PATCH END -----"
);
append_error_log(&log);
}
Ok(ApplyOutcome {
applied,
status,
message,
skipped_paths: r.skipped_paths,
conflict_paths: r.conflicted_paths,
})
}
}
fn details_path(base_url: &str, id: &str) -> Option<String> {
if base_url.contains("/backend-api") {
Some(format!("{base_url}/wham/tasks/{id}"))
} else if base_url.contains("/api/codex") {
Some(format!("{base_url}/tasks/{id}"))
} else {
None
}
}
fn extract_assistant_messages_from_body(body: &str) -> Vec<String> {
let mut msgs = Vec::new();
if let Ok(full) = serde_json::from_str::<serde_json::Value>(body)
&& let Some(arr) = full
.get("current_assistant_turn")
.and_then(|v| v.get("worklog"))
.and_then(|v| v.get("messages"))
.and_then(|v| v.as_array())
{
for m in arr {
let is_assistant = m
.get("author")
.and_then(|a| a.get("role"))
.and_then(|r| r.as_str())
== Some("assistant");
if !is_assistant {
continue;
}
if let Some(parts) = m
.get("content")
.and_then(|c| c.get("parts"))
.and_then(|p| p.as_array())
{
for p in parts {
if let Some(s) = p.as_str() {
if !s.is_empty() {
msgs.push(s.to_string());
}
continue;
}
if let Some(obj) = p.as_object()
&& obj.get("content_type").and_then(|t| t.as_str()) == Some("text")
&& let Some(txt) = obj.get("text").and_then(|t| t.as_str())
{
msgs.push(txt.to_string());
}
}
}
}
}
msgs
}
fn turn_attempt_from_map(turn: &HashMap<String, Value>) -> Option<TurnAttempt> {
let turn_id = turn.get("id").and_then(Value::as_str)?.to_string();
let attempt_placement = turn.get("attempt_placement").and_then(Value::as_i64);
let created_at = parse_timestamp_value(turn.get("created_at"));
let status = attempt_status_from_str(turn.get("turn_status").and_then(Value::as_str));
let diff = extract_diff_from_turn(turn);
let messages = extract_assistant_messages_from_turn(turn);
Some(TurnAttempt {
turn_id,
attempt_placement,
created_at,
status,
diff,
messages,
})
}
fn compare_attempts(a: &TurnAttempt, b: &TurnAttempt) -> Ordering {
match (a.attempt_placement, b.attempt_placement) {
(Some(lhs), Some(rhs)) => lhs.cmp(&rhs),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => match (a.created_at, b.created_at) {
(Some(lhs), Some(rhs)) => lhs.cmp(&rhs),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => a.turn_id.cmp(&b.turn_id),
},
}
}
fn extract_diff_from_turn(turn: &HashMap<String, Value>) -> Option<String> {
let items = turn.get("output_items").and_then(Value::as_array)?;
for item in items {
match item.get("type").and_then(Value::as_str) {
Some("output_diff") => {
if let Some(diff) = item.get("diff").and_then(Value::as_str)
&& !diff.is_empty()
{
return Some(diff.to_string());
}
}
Some("pr") => {
if let Some(diff) = item
.get("output_diff")
.and_then(Value::as_object)
.and_then(|od| od.get("diff"))
.and_then(Value::as_str)
&& !diff.is_empty()
{
return Some(diff.to_string());
}
}
_ => {}
}
}
None
}
fn extract_assistant_messages_from_turn(turn: &HashMap<String, Value>) -> Vec<String> {
let mut msgs = Vec::new();
if let Some(items) = turn.get("output_items").and_then(Value::as_array) {
for item in items {
if item.get("type").and_then(Value::as_str) != Some("message") {
continue;
}
if let Some(content) = item.get("content").and_then(Value::as_array) {
for part in content {
if part.get("content_type").and_then(Value::as_str) == Some("text")
&& let Some(txt) = part.get("text").and_then(Value::as_str)
&& !txt.is_empty()
{
msgs.push(txt.to_string());
}
}
}
}
}
msgs
}
fn attempt_status_from_str(raw: Option<&str>) -> AttemptStatus {
match raw.unwrap_or_default() {
"failed" => AttemptStatus::Failed,
"completed" => AttemptStatus::Completed,
"in_progress" => AttemptStatus::InProgress,
"pending" => AttemptStatus::Pending,
_ => AttemptStatus::Pending,
}
}
fn parse_timestamp_value(v: Option<&Value>) -> Option<DateTime<Utc>> {
let ts = v?.as_f64()?;
let secs = ts as i64;
let nanos = ((ts - secs as f64) * 1_000_000_000.0) as u32;
Some(DateTime::<Utc>::from(
std::time::UNIX_EPOCH + std::time::Duration::new(secs.max(0) as u64, nanos),
))
}
fn map_task_list_item_to_summary(src: backend::TaskListItem) -> TaskSummary {
let status_display = src.task_status_display.as_ref();
TaskSummary {
id: TaskId(src.id),
title: src.title,
status: map_status(status_display),
updated_at: parse_updated_at(src.updated_at.as_ref()),
environment_id: None,
environment_label: env_label_from_status_display(status_display),
summary: diff_summary_from_status_display(status_display),
is_review: src
.pull_requests
.as_ref()
.is_some_and(|prs| !prs.is_empty()),
attempt_total: attempt_total_from_status_display(status_display),
}
}
fn map_status(v: Option<&HashMap<String, Value>>) -> TaskStatus {
if let Some(val) = v {
if let Some(turn) = val
.get("latest_turn_status_display")
.and_then(Value::as_object)
&& let Some(s) = turn.get("turn_status").and_then(Value::as_str)
{
return match s {
"failed" => TaskStatus::Error,
"completed" => TaskStatus::Ready,
"in_progress" => TaskStatus::Pending,
"pending" => TaskStatus::Pending,
"cancelled" => TaskStatus::Error,
_ => TaskStatus::Pending,
};
}
if let Some(state) = val.get("state").and_then(Value::as_str) {
return match state {
"pending" => TaskStatus::Pending,
"ready" => TaskStatus::Ready,
"applied" => TaskStatus::Applied,
"error" => TaskStatus::Error,
_ => TaskStatus::Pending,
};
}
}
TaskStatus::Pending
}
fn parse_updated_at(ts: Option<&f64>) -> DateTime<Utc> {
if let Some(v) = ts {
let secs = *v as i64;
let nanos = ((*v - secs as f64) * 1_000_000_000.0) as u32;
return DateTime::<Utc>::from(
std::time::UNIX_EPOCH + std::time::Duration::new(secs.max(0) as u64, nanos),
);
}
Utc::now()
}
fn env_label_from_status_display(v: Option<&HashMap<String, Value>>) -> Option<String> {
let map = v?;
map.get("environment_label")
.and_then(Value::as_str)
.map(str::to_string)
}
fn diff_summary_from_status_display(v: Option<&HashMap<String, Value>>) -> DiffSummary {
let mut out = DiffSummary::default();
let Some(map) = v else { return out };
let latest = map
.get("latest_turn_status_display")
.and_then(Value::as_object);
let Some(latest) = latest else { return out };
if let Some(ds) = latest.get("diff_stats").and_then(Value::as_object) {
if let Some(n) = ds.get("files_modified").and_then(Value::as_i64) {
out.files_changed = n.max(0) as usize;
}
if let Some(n) = ds.get("lines_added").and_then(Value::as_i64) {
out.lines_added = n.max(0) as usize;
}
if let Some(n) = ds.get("lines_removed").and_then(Value::as_i64) {
out.lines_removed = n.max(0) as usize;
}
}
out
}
fn attempt_total_from_status_display(v: Option<&HashMap<String, Value>>) -> Option<usize> {
let map = v?;
let latest = map
.get("latest_turn_status_display")
.and_then(Value::as_object)?;
let siblings = latest.get("sibling_turn_ids").and_then(Value::as_array)?;
Some(siblings.len().saturating_add(1))
}
fn is_unified_diff(diff: &str) -> bool {
let t = diff.trim_start();
if t.starts_with("diff --git ") {
return true;
}
let has_dash_headers = diff.contains("\n--- ") && diff.contains("\n+++ ");
let has_hunk = diff.contains("\n@@ ") || diff.starts_with("@@ ");
has_dash_headers && has_hunk
}
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
s[s.len() - max..].to_string()
}
}
fn summarize_patch_for_logging(patch: &str) -> String {
let trimmed = patch.trim_start();
let kind = if trimmed.starts_with("*** Begin Patch") {
"codex-patch"
} else if trimmed.starts_with("diff --git ") || trimmed.contains("\n*** End Patch\n") {
"git-diff"
} else if trimmed.starts_with("@@ ") || trimmed.contains("\n@@ ") {
"unified-diff"
} else {
"unknown"
};
let lines = patch.lines().count();
let chars = patch.len();
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<unknown>".to_string());
let head: String = patch.lines().take(20).collect::<Vec<&str>>().join("\n");
let head_trunc = if head.len() > 800 {
format!("{}…", &head[..800])
} else {
head
};
format!(
"patch_summary: kind={kind} lines={lines} chars={chars} cwd={cwd} ; head=\n{head_trunc}"
)
}
}
fn append_error_log(message: &str) {
let ts = Utc::now().to_rfc3339();
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("error.log")
{
use std::io::Write as _;
let _ = writeln!(f, "[{ts}] {message}");
}
}
+29
View File
@@ -0,0 +1,29 @@
mod api;
pub use api::ApplyOutcome;
pub use api::ApplyStatus;
pub use api::AttemptStatus;
pub use api::CloudBackend;
pub use api::CloudTaskError;
pub use api::CreatedTask;
pub use api::DiffSummary;
pub use api::Result;
pub use api::TaskId;
pub use api::TaskStatus;
pub use api::TaskSummary;
pub use api::TaskText;
pub use api::TurnAttempt;
#[cfg(feature = "mock")]
mod mock;
#[cfg(feature = "online")]
mod http;
#[cfg(feature = "mock")]
pub use mock::MockClient;
#[cfg(feature = "online")]
pub use http::HttpClient;
// Reusable apply engine now lives in the shared crate `codex-git-apply`.
+180
View File
@@ -0,0 +1,180 @@
use crate::ApplyOutcome;
use crate::AttemptStatus;
use crate::CloudBackend;
use crate::DiffSummary;
use crate::Result;
use crate::TaskId;
use crate::TaskStatus;
use crate::TaskSummary;
use crate::TurnAttempt;
use crate::api::TaskText;
use chrono::Utc;
#[derive(Clone, Default)]
pub struct MockClient;
#[async_trait::async_trait]
impl CloudBackend for MockClient {
async fn list_tasks(&self, _env: Option<&str>) -> Result<Vec<TaskSummary>> {
// Slightly vary content by env to aid tests that rely on the mock
let rows = match _env {
Some("env-A") => vec![("T-2000", "A: First", TaskStatus::Ready)],
Some("env-B") => vec![
("T-3000", "B: One", TaskStatus::Ready),
("T-3001", "B: Two", TaskStatus::Pending),
],
_ => vec![
("T-1000", "Update README formatting", TaskStatus::Ready),
("T-1001", "Fix clippy warnings in core", TaskStatus::Pending),
("T-1002", "Add contributing guide", TaskStatus::Ready),
],
};
let environment_id = _env.map(str::to_string);
let environment_label = match _env {
Some("env-A") => Some("Env A".to_string()),
Some("env-B") => Some("Env B".to_string()),
Some(other) => Some(other.to_string()),
None => Some("Global".to_string()),
};
let mut out = Vec::new();
for (id_str, title, status) in rows {
let id = TaskId(id_str.to_string());
let diff = mock_diff_for(&id);
let (a, d) = count_from_unified(&diff);
out.push(TaskSummary {
id,
title: title.to_string(),
status,
updated_at: Utc::now(),
environment_id: environment_id.clone(),
environment_label: environment_label.clone(),
summary: DiffSummary {
files_changed: 1,
lines_added: a,
lines_removed: d,
},
is_review: false,
attempt_total: Some(if id_str == "T-1000" { 2 } else { 1 }),
});
}
Ok(out)
}
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>> {
Ok(Some(mock_diff_for(&id)))
}
async fn get_task_messages(&self, _id: TaskId) -> Result<Vec<String>> {
Ok(vec![
"Mock assistant output: this task contains no diff.".to_string(),
])
}
async fn get_task_text(&self, _id: TaskId) -> Result<TaskText> {
Ok(TaskText {
prompt: Some("Why is there no diff?".to_string()),
messages: vec!["Mock assistant output: this task contains no diff.".to_string()],
turn_id: Some("mock-turn".to_string()),
sibling_turn_ids: Vec::new(),
attempt_placement: Some(0),
attempt_status: AttemptStatus::Completed,
})
}
async fn apply_task(&self, id: TaskId, _diff_override: Option<String>) -> Result<ApplyOutcome> {
Ok(ApplyOutcome {
applied: true,
status: crate::ApplyStatus::Success,
message: format!("Applied task {} locally (mock)", id.0),
skipped_paths: Vec::new(),
conflict_paths: Vec::new(),
})
}
async fn apply_task_preflight(
&self,
id: TaskId,
_diff_override: Option<String>,
) -> Result<ApplyOutcome> {
Ok(ApplyOutcome {
applied: false,
status: crate::ApplyStatus::Success,
message: format!("Preflight passed for task {} (mock)", id.0),
skipped_paths: Vec::new(),
conflict_paths: Vec::new(),
})
}
async fn list_sibling_attempts(
&self,
task: TaskId,
_turn_id: String,
) -> Result<Vec<TurnAttempt>> {
if task.0 == "T-1000" {
return Ok(vec![TurnAttempt {
turn_id: "T-1000-attempt-2".to_string(),
attempt_placement: Some(1),
created_at: Some(Utc::now()),
status: AttemptStatus::Completed,
diff: Some(mock_diff_for(&task)),
messages: vec!["Mock alternate attempt".to_string()],
}]);
}
Ok(Vec::new())
}
async fn create_task(
&self,
env_id: &str,
prompt: &str,
git_ref: &str,
qa_mode: bool,
best_of_n: usize,
) -> Result<crate::CreatedTask> {
let _ = (env_id, prompt, git_ref, qa_mode, best_of_n);
let id = format!("task_local_{}", chrono::Utc::now().timestamp_millis());
Ok(crate::CreatedTask { id: TaskId(id) })
}
}
fn mock_diff_for(id: &TaskId) -> String {
match id.0.as_str() {
"T-1000" => {
"diff --git a/README.md b/README.md\nindex 000000..111111 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,2 +1,3 @@\n Intro\n-Hello\n+Hello, world!\n+Task: T-1000\n".to_string()
}
"T-1001" => {
"diff --git a/core/src/lib.rs b/core/src/lib.rs\nindex 000000..111111 100644\n--- a/core/src/lib.rs\n+++ b/core/src/lib.rs\n@@ -1,2 +1,1 @@\n-use foo;\n use bar;\n".to_string()
}
_ => {
"diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md\nindex 000000..111111 100644\n--- /dev/null\n+++ b/CONTRIBUTING.md\n@@ -0,0 +1,3 @@\n+## Contributing\n+Please open PRs.\n+Thanks!\n".to_string()
}
}
}
fn count_from_unified(diff: &str) -> (usize, usize) {
if let Ok(patch) = diffy::Patch::from_str(diff) {
patch
.hunks()
.iter()
.flat_map(diffy::Hunk::lines)
.fold((0, 0), |(a, d), l| match l {
diffy::Line::Insert(_) => (a + 1, d),
diffy::Line::Delete(_) => (a, d + 1),
_ => (a, d),
})
} else {
let mut a = 0;
let mut d = 0;
for l in diff.lines() {
if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
continue;
}
match l.as_bytes().first() {
Some(b'+') => a += 1,
Some(b'-') => d += 1,
_ => {}
}
}
(a, d)
}
}
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "codex-cloud-tasks"
version = { workspace = true }
edition = "2024"
[lib]
name = "codex_cloud_tasks"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
codex-common = { path = "../common", features = ["cli"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tracing = { version = "0.1.41", features = ["log"] }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = ["mock", "online"] }
ratatui = { version = "0.29.0" }
crossterm = { version = "0.28.1", features = ["event-stream"] }
tokio-stream = "0.1.17"
chrono = { version = "0.4", features = ["serde"] }
codex-login = { path = "../login" }
codex-core = { path = "../core" }
throbber-widgets-tui = "0.8.0"
base64 = "0.22"
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
unicode-width = "0.1"
codex-tui = { path = "../tui" }
[dev-dependencies]
async-trait = "0.1"
+482
View File
@@ -0,0 +1,482 @@
use std::time::Duration;
// Environment filter data models for the TUI
#[derive(Clone, Debug, Default)]
pub struct EnvironmentRow {
pub id: String,
pub label: Option<String>,
pub is_pinned: bool,
pub repo_hints: Option<String>, // e.g., "openai/codex"
}
#[derive(Clone, Debug, Default)]
pub struct EnvModalState {
pub query: String,
pub selected: usize,
}
#[derive(Clone, Debug, Default)]
pub struct BestOfModalState {
pub selected: usize,
}
#[derive(Clone, Debug, Copy, PartialEq, Eq)]
pub enum ApplyResultLevel {
Success,
Partial,
Error,
}
#[derive(Clone, Debug)]
pub struct ApplyModalState {
pub task_id: TaskId,
pub title: String,
pub result_message: Option<String>,
pub result_level: Option<ApplyResultLevel>,
pub skipped_paths: Vec<String>,
pub conflict_paths: Vec<String>,
pub diff_override: Option<String>,
}
use crate::scrollable_diff::ScrollableDiff;
use codex_cloud_tasks_client::CloudBackend;
use codex_cloud_tasks_client::TaskId;
use codex_cloud_tasks_client::TaskSummary;
use throbber_widgets_tui::ThrobberState;
#[derive(Default)]
pub struct App {
pub tasks: Vec<TaskSummary>,
pub selected: usize,
pub status: String,
pub diff_overlay: Option<DiffOverlay>,
pub throbber: ThrobberState,
pub refresh_inflight: bool,
pub details_inflight: bool,
// Environment filter state
pub env_filter: Option<String>,
pub env_modal: Option<EnvModalState>,
pub apply_modal: Option<ApplyModalState>,
pub best_of_modal: Option<BestOfModalState>,
pub environments: Vec<EnvironmentRow>,
pub env_last_loaded: Option<std::time::Instant>,
pub env_loading: bool,
pub env_error: Option<String>,
// New Task page
pub new_task: Option<crate::new_task::NewTaskPage>,
pub best_of_n: usize,
// Apply preflight spinner state
pub apply_preflight_inflight: bool,
// Apply action spinner state
pub apply_inflight: bool,
// Background enrichment coordination
pub list_generation: u64,
pub in_flight: std::collections::HashSet<String>,
// Background enrichment caches were planned; currently unused.
}
impl App {
pub fn new() -> Self {
Self {
tasks: Vec::new(),
selected: 0,
status: "Press r to refresh".to_string(),
diff_overlay: None,
throbber: ThrobberState::default(),
refresh_inflight: false,
details_inflight: false,
env_filter: None,
env_modal: None,
apply_modal: None,
best_of_modal: None,
environments: Vec::new(),
env_last_loaded: None,
env_loading: false,
env_error: None,
new_task: None,
best_of_n: 1,
apply_preflight_inflight: false,
apply_inflight: false,
list_generation: 0,
in_flight: std::collections::HashSet::new(),
}
}
pub fn next(&mut self) {
if self.tasks.is_empty() {
return;
}
self.selected = (self.selected + 1).min(self.tasks.len().saturating_sub(1));
}
pub fn prev(&mut self) {
if self.tasks.is_empty() {
return;
}
if self.selected > 0 {
self.selected -= 1;
}
}
}
pub async fn load_tasks(
backend: &dyn CloudBackend,
env: Option<&str>,
) -> anyhow::Result<Vec<TaskSummary>> {
// In later milestones, add a small debounce, spinner, and error display.
let tasks = tokio::time::timeout(Duration::from_secs(5), backend.list_tasks(env)).await??;
// Hide review-only tasks from the main list.
let filtered: Vec<TaskSummary> = tasks.into_iter().filter(|t| !t.is_review).collect();
Ok(filtered)
}
pub struct DiffOverlay {
pub title: String,
pub task_id: TaskId,
pub sd: ScrollableDiff,
pub base_can_apply: bool,
pub diff_lines: Vec<String>,
pub text_lines: Vec<String>,
pub prompt: Option<String>,
pub attempts: Vec<AttemptView>,
pub selected_attempt: usize,
pub current_view: DetailView,
pub base_turn_id: Option<String>,
pub sibling_turn_ids: Vec<String>,
pub attempt_total_hint: Option<usize>,
}
#[derive(Clone, Debug, Default)]
pub struct AttemptView {
pub turn_id: Option<String>,
pub status: codex_cloud_tasks_client::AttemptStatus,
pub attempt_placement: Option<i64>,
pub diff_lines: Vec<String>,
pub text_lines: Vec<String>,
pub prompt: Option<String>,
pub diff_raw: Option<String>,
}
impl AttemptView {
pub fn has_diff(&self) -> bool {
!self.diff_lines.is_empty()
}
pub fn has_text(&self) -> bool {
!self.text_lines.is_empty() || self.prompt.is_some()
}
}
impl DiffOverlay {
pub fn new(task_id: TaskId, title: String, attempt_total_hint: Option<usize>) -> Self {
let mut sd = ScrollableDiff::new();
sd.set_content(Vec::new());
Self {
title,
task_id,
sd,
base_can_apply: false,
diff_lines: Vec::new(),
text_lines: Vec::new(),
prompt: None,
attempts: vec![AttemptView::default()],
selected_attempt: 0,
current_view: DetailView::Prompt,
base_turn_id: None,
sibling_turn_ids: Vec::new(),
attempt_total_hint,
}
}
pub fn current_attempt(&self) -> Option<&AttemptView> {
self.attempts.get(self.selected_attempt)
}
pub fn base_attempt_mut(&mut self) -> &mut AttemptView {
if self.attempts.is_empty() {
self.attempts.push(AttemptView::default());
}
&mut self.attempts[0]
}
pub fn set_view(&mut self, view: DetailView) {
self.current_view = view;
self.apply_selection_to_fields();
}
pub fn expected_attempts(&self) -> Option<usize> {
self.attempt_total_hint.or({
if self.attempts.is_empty() {
None
} else {
Some(self.attempts.len())
}
})
}
pub fn attempt_count(&self) -> usize {
self.attempts.len()
}
pub fn attempt_display_total(&self) -> usize {
self.expected_attempts()
.unwrap_or_else(|| self.attempts.len().max(1))
}
pub fn step_attempt(&mut self, delta: isize) -> bool {
let total = self.attempts.len();
if total <= 1 {
return false;
}
let total_isize = total as isize;
let current = self.selected_attempt as isize;
let mut next = current + delta;
next = ((next % total_isize) + total_isize) % total_isize;
let next = next as usize;
self.selected_attempt = next;
self.apply_selection_to_fields();
true
}
pub fn current_can_apply(&self) -> bool {
matches!(self.current_view, DetailView::Diff)
&& self
.current_attempt()
.and_then(|attempt| attempt.diff_raw.as_ref())
.map(|diff| !diff.is_empty())
.unwrap_or(false)
}
pub fn apply_selection_to_fields(&mut self) {
let (diff_lines, text_lines, prompt) = if let Some(attempt) = self.current_attempt() {
(
attempt.diff_lines.clone(),
attempt.text_lines.clone(),
attempt.prompt.clone(),
)
} else {
self.diff_lines.clear();
self.text_lines.clear();
self.prompt = None;
self.sd.set_content(vec!["<loading attempt>".to_string()]);
return;
};
self.diff_lines = diff_lines.clone();
self.text_lines = text_lines.clone();
self.prompt = prompt;
match self.current_view {
DetailView::Diff => {
if diff_lines.is_empty() {
self.sd.set_content(vec!["<no diff available>".to_string()]);
} else {
self.sd.set_content(diff_lines);
}
}
DetailView::Prompt => {
if text_lines.is_empty() {
self.sd.set_content(vec!["<no output>".to_string()]);
} else {
self.sd.set_content(text_lines);
}
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DetailView {
Diff,
Prompt,
}
/// Internal app events delivered from background tasks.
/// These let the UI event loop remain responsive and keep the spinner animating.
#[derive(Debug)]
pub enum AppEvent {
TasksLoaded {
env: Option<String>,
result: anyhow::Result<Vec<TaskSummary>>,
},
// Background diff summary events were planned; removed for now to keep code minimal.
/// Autodetection of a likely environment id finished
EnvironmentAutodetected(anyhow::Result<crate::env_detect::AutodetectSelection>),
/// Background completion of environment list fetch
EnvironmentsLoaded(anyhow::Result<Vec<EnvironmentRow>>),
DetailsDiffLoaded {
id: TaskId,
title: String,
diff: String,
},
DetailsMessagesLoaded {
id: TaskId,
title: String,
messages: Vec<String>,
prompt: Option<String>,
turn_id: Option<String>,
sibling_turn_ids: Vec<String>,
attempt_placement: Option<i64>,
attempt_status: codex_cloud_tasks_client::AttemptStatus,
},
DetailsFailed {
id: TaskId,
title: String,
error: String,
},
AttemptsLoaded {
id: TaskId,
attempts: Vec<codex_cloud_tasks_client::TurnAttempt>,
},
/// Background completion of new task submission
NewTaskSubmitted(Result<codex_cloud_tasks_client::CreatedTask, String>),
/// Background completion of apply preflight when opening modal or on demand
ApplyPreflightFinished {
id: TaskId,
title: String,
message: String,
level: ApplyResultLevel,
skipped: Vec<String>,
conflicts: Vec<String>,
},
/// Background completion of apply action (actual patch application)
ApplyFinished {
id: TaskId,
result: std::result::Result<codex_cloud_tasks_client::ApplyOutcome, String>,
},
}
// Convenience aliases; currently unused.
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
struct FakeBackend {
// maps env key to titles
by_env: std::collections::HashMap<Option<String>, Vec<&'static str>>,
}
#[async_trait::async_trait]
impl codex_cloud_tasks_client::CloudBackend for FakeBackend {
async fn list_tasks(
&self,
env: Option<&str>,
) -> codex_cloud_tasks_client::Result<Vec<TaskSummary>> {
let key = env.map(str::to_string);
let titles = self
.by_env
.get(&key)
.cloned()
.unwrap_or_else(|| vec!["default-a", "default-b"]);
let mut out = Vec::new();
for (i, t) in titles.into_iter().enumerate() {
out.push(TaskSummary {
id: TaskId(format!("T-{i}")),
title: t.to_string(),
status: codex_cloud_tasks_client::TaskStatus::Ready,
updated_at: Utc::now(),
environment_id: env.map(str::to_string),
environment_label: None,
summary: codex_cloud_tasks_client::DiffSummary::default(),
is_review: false,
attempt_total: Some(1),
});
}
Ok(out)
}
async fn get_task_diff(
&self,
_id: TaskId,
) -> codex_cloud_tasks_client::Result<Option<String>> {
Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented(
"not used in test",
))
}
async fn get_task_messages(
&self,
_id: TaskId,
) -> codex_cloud_tasks_client::Result<Vec<String>> {
Ok(vec![])
}
async fn get_task_text(
&self,
_id: TaskId,
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::TaskText> {
Ok(codex_cloud_tasks_client::TaskText {
prompt: Some("Example prompt".to_string()),
messages: Vec::new(),
turn_id: Some("fake-turn".to_string()),
sibling_turn_ids: Vec::new(),
attempt_placement: Some(0),
attempt_status: codex_cloud_tasks_client::AttemptStatus::Completed,
})
}
async fn list_sibling_attempts(
&self,
_task: TaskId,
_turn_id: String,
) -> codex_cloud_tasks_client::Result<Vec<codex_cloud_tasks_client::TurnAttempt>> {
Ok(Vec::new())
}
async fn apply_task(
&self,
_id: TaskId,
_diff_override: Option<String>,
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::ApplyOutcome> {
Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented(
"not used in test",
))
}
async fn apply_task_preflight(
&self,
_id: TaskId,
_diff_override: Option<String>,
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::ApplyOutcome> {
Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented(
"not used in test",
))
}
async fn create_task(
&self,
_env_id: &str,
_prompt: &str,
_git_ref: &str,
_qa_mode: bool,
_best_of_n: usize,
) -> codex_cloud_tasks_client::Result<codex_cloud_tasks_client::CreatedTask> {
Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented(
"not used in test",
))
}
}
#[tokio::test]
async fn load_tasks_uses_env_parameter() {
// Arrange: env-specific task titles
let mut by_env = std::collections::HashMap::new();
by_env.insert(None, vec!["root-1", "root-2"]);
by_env.insert(Some("env-A".to_string()), vec!["A-1"]);
by_env.insert(Some("env-B".to_string()), vec!["B-1", "B-2", "B-3"]);
let backend = FakeBackend { by_env };
// Act + Assert
let root = load_tasks(&backend, None).await.unwrap();
assert_eq!(root.len(), 2);
assert_eq!(root[0].title, "root-1");
let a = load_tasks(&backend, Some("env-A")).await.unwrap();
assert_eq!(a.len(), 1);
assert_eq!(a[0].title, "A-1");
let b = load_tasks(&backend, Some("env-B")).await.unwrap();
assert_eq!(b.len(), 3);
assert_eq!(b[2].title, "B-3");
}
}
+9
View File
@@ -0,0 +1,9 @@
use clap::Parser;
use codex_common::CliConfigOverrides;
#[derive(Parser, Debug, Default)]
#[command(version)]
pub struct Cli {
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}
+361
View File
@@ -0,0 +1,361 @@
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap;
use std::collections::HashMap;
use tracing::info;
use tracing::warn;
#[derive(Debug, Clone, serde::Deserialize)]
struct CodeEnvironment {
id: String,
#[serde(default)]
label: Option<String>,
#[serde(default)]
is_pinned: Option<bool>,
#[serde(default)]
task_count: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct AutodetectSelection {
pub id: String,
pub label: Option<String>,
}
pub async fn autodetect_environment_id(
base_url: &str,
headers: &HeaderMap,
desired_label: Option<String>,
) -> anyhow::Result<AutodetectSelection> {
// 1) Try repo-specific environments based on local git origins (GitHub only, like VSCode)
let origins = get_git_origins();
crate::append_error_log(format!("env: git origins: {origins:?}"));
let mut by_repo_envs: Vec<CodeEnvironment> = Vec::new();
for origin in &origins {
if let Some((owner, repo)) = parse_owner_repo(origin) {
let url = if base_url.contains("/backend-api") {
format!(
"{}/wham/environments/by-repo/{}/{}/{}",
base_url, "github", owner, repo
)
} else {
format!(
"{}/api/codex/environments/by-repo/{}/{}/{}",
base_url, "github", owner, repo
)
};
crate::append_error_log(format!("env: GET {url}"));
match get_json::<Vec<CodeEnvironment>>(&url, headers).await {
Ok(mut list) => {
crate::append_error_log(format!(
"env: by-repo returned {} env(s) for {owner}/{repo}",
list.len(),
));
by_repo_envs.append(&mut list);
}
Err(e) => crate::append_error_log(format!(
"env: by-repo fetch failed for {owner}/{repo}: {e}"
)),
}
}
}
if let Some(env) = pick_environment_row(&by_repo_envs, desired_label.as_deref()) {
return Ok(AutodetectSelection {
id: env.id.clone(),
label: env.label.as_deref().map(str::to_owned),
});
}
// 2) Fallback to the full list
let list_url = if base_url.contains("/backend-api") {
format!("{base_url}/wham/environments")
} else {
format!("{base_url}/api/codex/environments")
};
crate::append_error_log(format!("env: GET {list_url}"));
// Fetch and log the full environments JSON for debugging
let http = reqwest::Client::builder().build()?;
let res = http.get(&list_url).headers(headers.clone()).send().await?;
let status = res.status();
let ct = res
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = res.text().await.unwrap_or_default();
crate::append_error_log(format!("env: status={status} content-type={ct}"));
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(v) => {
let pretty = serde_json::to_string_pretty(&v).unwrap_or(body.clone());
crate::append_error_log(format!("env: /environments JSON (pretty):\n{pretty}"));
}
Err(_) => crate::append_error_log(format!("env: /environments (raw):\n{body}")),
}
if !status.is_success() {
anyhow::bail!("GET {list_url} failed: {status}; content-type={ct}; body={body}");
}
let all_envs: Vec<CodeEnvironment> = serde_json::from_str(&body).map_err(|e| {
anyhow::anyhow!("Decode error for {list_url}: {e}; content-type={ct}; body={body}")
})?;
if let Some(env) = pick_environment_row(&all_envs, desired_label.as_deref()) {
return Ok(AutodetectSelection {
id: env.id.clone(),
label: env.label.as_deref().map(str::to_owned),
});
}
anyhow::bail!("no environments available")
}
fn pick_environment_row(
envs: &[CodeEnvironment],
desired_label: Option<&str>,
) -> Option<CodeEnvironment> {
if envs.is_empty() {
return None;
}
if let Some(label) = desired_label {
let lc = label.to_lowercase();
if let Some(e) = envs
.iter()
.find(|e| e.label.as_deref().unwrap_or("").to_lowercase() == lc)
{
crate::append_error_log(format!("env: matched by label: {label} -> {}", e.id));
return Some(e.clone());
}
}
if envs.len() == 1 {
crate::append_error_log("env: single environment available; selecting it");
return Some(envs[0].clone());
}
if let Some(e) = envs.iter().find(|e| e.is_pinned.unwrap_or(false)) {
crate::append_error_log(format!("env: selecting pinned environment: {}", e.id));
return Some(e.clone());
}
// Highest task_count as heuristic
if let Some(e) = envs
.iter()
.max_by_key(|e| e.task_count.unwrap_or(0))
.or_else(|| envs.first())
{
crate::append_error_log(format!("env: selecting by task_count/first: {}", e.id));
return Some(e.clone());
}
None
}
async fn get_json<T: serde::de::DeserializeOwned>(
url: &str,
headers: &HeaderMap,
) -> anyhow::Result<T> {
let http = reqwest::Client::builder().build()?;
let res = http.get(url).headers(headers.clone()).send().await?;
let status = res.status();
let ct = res
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = res.text().await.unwrap_or_default();
crate::append_error_log(format!("env: status={status} content-type={ct}"));
if !status.is_success() {
anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}");
}
let parsed = serde_json::from_str::<T>(&body).map_err(|e| {
anyhow::anyhow!("Decode error for {url}: {e}; content-type={ct}; body={body}")
})?;
Ok(parsed)
}
fn get_git_origins() -> Vec<String> {
// Prefer: git config --get-regexp remote\..*\.url
let out = std::process::Command::new("git")
.args(["config", "--get-regexp", "remote\\..*\\.url"])
.output();
if let Ok(ok) = out
&& ok.status.success()
{
let s = String::from_utf8_lossy(&ok.stdout);
let mut urls = Vec::new();
for line in s.lines() {
if let Some((_, url)) = line.split_once(' ') {
urls.push(url.trim().to_string());
}
}
if !urls.is_empty() {
return uniq(urls);
}
}
// Fallback: git remote -v
let out = std::process::Command::new("git")
.args(["remote", "-v"])
.output();
if let Ok(ok) = out
&& ok.status.success()
{
let s = String::from_utf8_lossy(&ok.stdout);
let mut urls = Vec::new();
for line in s.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
urls.push(parts[1].to_string());
}
}
if !urls.is_empty() {
return uniq(urls);
}
}
Vec::new()
}
fn uniq(mut v: Vec<String>) -> Vec<String> {
v.sort();
v.dedup();
v
}
fn parse_owner_repo(url: &str) -> Option<(String, String)> {
// Normalize common prefixes and handle multiple SSH/HTTPS variants.
let mut s = url.trim().to_string();
// Drop protocol scheme for ssh URLs
if let Some(rest) = s.strip_prefix("ssh://") {
s = rest.to_string();
}
// Accept any user before @github.com (e.g., git@, org-123@)
if let Some(idx) = s.find("@github.com:") {
let rest = &s[idx + "@github.com:".len()..];
let rest = rest.trim_start_matches('/').trim_end_matches(".git");
let mut parts = rest.splitn(2, '/');
let owner = parts.next()?.to_string();
let repo = parts.next()?.to_string();
crate::append_error_log(format!("env: parsed SSH GitHub origin => {owner}/{repo}"));
return Some((owner, repo));
}
// HTTPS or git protocol
for prefix in [
"https://github.com/",
"http://github.com/",
"git://github.com/",
"github.com/",
] {
if let Some(rest) = s.strip_prefix(prefix) {
let rest = rest.trim_start_matches('/').trim_end_matches(".git");
let mut parts = rest.splitn(2, '/');
let owner = parts.next()?.to_string();
let repo = parts.next()?.to_string();
crate::append_error_log(format!("env: parsed HTTP GitHub origin => {owner}/{repo}"));
return Some((owner, repo));
}
}
None
}
/// List environments for the current repo(s) with a fallback to the global list.
/// Returns a de-duplicated, sorted set suitable for the TUI modal.
pub async fn list_environments(
base_url: &str,
headers: &HeaderMap,
) -> anyhow::Result<Vec<crate::app::EnvironmentRow>> {
let mut map: HashMap<String, crate::app::EnvironmentRow> = HashMap::new();
// 1) By-repo lookup for each parsed GitHub origin
let origins = get_git_origins();
for origin in &origins {
if let Some((owner, repo)) = parse_owner_repo(origin) {
let url = if base_url.contains("/backend-api") {
format!(
"{}/wham/environments/by-repo/{}/{}/{}",
base_url, "github", owner, repo
)
} else {
format!(
"{}/api/codex/environments/by-repo/{}/{}/{}",
base_url, "github", owner, repo
)
};
match get_json::<Vec<CodeEnvironment>>(&url, headers).await {
Ok(list) => {
info!("env_tui: by-repo {}:{} -> {} envs", owner, repo, list.len());
for e in list {
let entry =
map.entry(e.id.clone())
.or_insert_with(|| crate::app::EnvironmentRow {
id: e.id.clone(),
label: e.label.clone(),
is_pinned: e.is_pinned.unwrap_or(false),
repo_hints: Some(format!("{owner}/{repo}")),
});
// Merge: keep label if present, or use new; accumulate pinned flag
if entry.label.is_none() {
entry.label = e.label.clone();
}
entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false);
if entry.repo_hints.is_none() {
entry.repo_hints = Some(format!("{owner}/{repo}"));
}
}
}
Err(e) => {
warn!(
"env_tui: by-repo fetch failed for {}/{}: {}",
owner, repo, e
);
}
}
}
}
// 2) Fallback to the full list; on error return what we have if any.
let list_url = if base_url.contains("/backend-api") {
format!("{base_url}/wham/environments")
} else {
format!("{base_url}/api/codex/environments")
};
match get_json::<Vec<CodeEnvironment>>(&list_url, headers).await {
Ok(list) => {
info!("env_tui: global list -> {} envs", list.len());
for e in list {
let entry = map
.entry(e.id.clone())
.or_insert_with(|| crate::app::EnvironmentRow {
id: e.id.clone(),
label: e.label.clone(),
is_pinned: e.is_pinned.unwrap_or(false),
repo_hints: None,
});
if entry.label.is_none() {
entry.label = e.label.clone();
}
entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false);
}
}
Err(e) => {
if map.is_empty() {
return Err(e);
} else {
warn!(
"env_tui: global list failed; using by-repo results only: {}",
e
);
}
}
}
let mut rows: Vec<crate::app::EnvironmentRow> = map.into_values().collect();
rows.sort_by(|a, b| {
// pinned first
let p = b.is_pinned.cmp(&a.is_pinned);
if p != std::cmp::Ordering::Equal {
return p;
}
// then label (ci), then id
let al = a.label.as_deref().unwrap_or("").to_lowercase();
let bl = b.label.as_deref().unwrap_or("").to_lowercase();
let l = al.cmp(&bl);
if l != std::cmp::Ordering::Equal {
return l;
}
a.id.cmp(&b.id)
});
Ok(rows)
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
use codex_tui::ComposerInput;
pub struct NewTaskPage {
pub composer: ComposerInput,
pub submitting: bool,
pub env_id: Option<String>,
pub best_of_n: usize,
}
impl NewTaskPage {
pub fn new(env_id: Option<String>, best_of_n: usize) -> Self {
let mut composer = ComposerInput::new();
composer.set_hint_items(vec![
("⏎", "send"),
("Shift+⏎", "newline"),
("Ctrl+O", "env"),
("Ctrl+N", "attempts"),
("Ctrl+C", "quit"),
]);
Self {
composer,
submitting: false,
env_id,
best_of_n,
}
}
// Additional helpers can be added as usage evolves.
}
impl Default for NewTaskPage {
fn default() -> Self {
Self::new(None, 1)
}
}
+176
View File
@@ -0,0 +1,176 @@
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
/// Scroll position and geometry for a vertical scroll view.
#[derive(Clone, Copy, Debug, Default)]
pub struct ScrollViewState {
pub scroll: u16,
pub viewport_h: u16,
pub content_h: u16,
}
impl ScrollViewState {
pub fn clamp(&mut self) {
let max_scroll = self.content_h.saturating_sub(self.viewport_h);
if self.scroll > max_scroll {
self.scroll = max_scroll;
}
}
}
/// A simple, local scrollable view for diffs or message text.
///
/// Owns raw lines, caches wrapped lines for a given width, and maintains
/// a small scroll state that is clamped whenever geometry shrinks.
#[derive(Clone, Debug, Default)]
pub struct ScrollableDiff {
raw: Vec<String>,
wrapped: Vec<String>,
wrapped_src_idx: Vec<usize>,
wrap_cols: Option<u16>,
pub state: ScrollViewState,
}
impl ScrollableDiff {
pub fn new() -> Self {
Self::default()
}
/// Replace the raw content lines. Does not rewrap immediately; call `set_width` next.
pub fn set_content(&mut self, lines: Vec<String>) {
self.raw = lines;
self.wrapped.clear();
self.wrapped_src_idx.clear();
self.state.content_h = 0;
// Force rewrap on next set_width even if width is unchanged
self.wrap_cols = None;
}
/// Set the wrap width. If changed, rebuild wrapped lines and clamp scroll.
pub fn set_width(&mut self, width: u16) {
if self.wrap_cols == Some(width) {
return;
}
self.wrap_cols = Some(width);
self.rewrap(width);
self.state.clamp();
}
/// Update viewport height and clamp scroll if needed.
pub fn set_viewport(&mut self, height: u16) {
self.state.viewport_h = height;
self.state.clamp();
}
/// Return the cached wrapped lines. Call `set_width` first when area changes.
pub fn wrapped_lines(&self) -> &[String] {
&self.wrapped
}
pub fn wrapped_src_indices(&self) -> &[usize] {
&self.wrapped_src_idx
}
pub fn raw_line_at(&self, idx: usize) -> &str {
self.raw.get(idx).map(String::as_str).unwrap_or("")
}
/// Scroll by a signed delta; clamps to content.
pub fn scroll_by(&mut self, delta: i16) {
let s = self.state.scroll as i32 + delta as i32;
self.state.scroll = s.clamp(0, self.max_scroll() as i32) as u16;
}
/// Page by a signed delta; typically viewport_h - 1.
pub fn page_by(&mut self, delta: i16) {
self.scroll_by(delta);
}
pub fn to_top(&mut self) {
self.state.scroll = 0;
}
pub fn to_bottom(&mut self) {
self.state.scroll = self.max_scroll();
}
/// Optional percent scrolled; None when not enough geometry is known.
pub fn percent_scrolled(&self) -> Option<u8> {
if self.state.content_h == 0 || self.state.viewport_h == 0 {
return None;
}
if self.state.content_h <= self.state.viewport_h {
return None;
}
let visible_bottom = self.state.scroll.saturating_add(self.state.viewport_h) as f32;
let pct = (visible_bottom / self.state.content_h as f32 * 100.0).round();
Some(pct.clamp(0.0, 100.0) as u8)
}
fn max_scroll(&self) -> u16 {
self.state.content_h.saturating_sub(self.state.viewport_h)
}
fn rewrap(&mut self, width: u16) {
if width == 0 {
self.wrapped = self.raw.clone();
self.state.content_h = self.wrapped.len() as u16;
return;
}
let max_cols = width as usize;
let mut out: Vec<String> = Vec::new();
let mut out_idx: Vec<usize> = Vec::new();
for (raw_idx, raw) in self.raw.iter().enumerate() {
// Normalize tabs for width accounting (MVP: 4 spaces).
let raw = raw.replace('\t', " ");
if raw.is_empty() {
out.push(String::new());
out_idx.push(raw_idx);
continue;
}
let mut line = String::new();
let mut line_cols = 0usize;
let mut last_soft_idx: Option<usize> = None; // last whitespace or punctuation break
for (_i, ch) in raw.char_indices() {
if ch == '\n' {
out.push(std::mem::take(&mut line));
out_idx.push(raw_idx);
line_cols = 0;
last_soft_idx = None;
continue;
}
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if line_cols.saturating_add(w) > max_cols {
if let Some(split) = last_soft_idx {
let (prefix, rest) = line.split_at(split);
out.push(prefix.trim_end().to_string());
out_idx.push(raw_idx);
line = rest.trim_start().to_string();
last_soft_idx = None;
// retry add current ch now that line may be shorter
} else if !line.is_empty() {
out.push(std::mem::take(&mut line));
out_idx.push(raw_idx);
}
}
if ch.is_whitespace()
|| matches!(
ch,
',' | ';' | '.' | ':' | ')' | ']' | '}' | '|' | '/' | '?' | '!' | '-' | '_'
)
{
last_soft_idx = Some(line.len());
}
line.push(ch);
line_cols = UnicodeWidthStr::width(line.as_str());
}
if !line.is_empty() {
out.push(line);
out_idx.push(raw_idx);
}
}
self.wrapped = out;
self.wrapped_src_idx = out_idx;
self.state.content_h = self.wrapped.len() as u16;
}
}
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
use base64::Engine as _;
use chrono::Utc;
use reqwest::header::HeaderMap;
pub fn set_user_agent_suffix(suffix: &str) {
if let Ok(mut guard) = codex_core::default_client::USER_AGENT_SUFFIX.lock() {
guard.replace(suffix.to_string());
}
}
pub fn append_error_log(message: impl AsRef<str>) {
let ts = Utc::now().to_rfc3339();
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("error.log")
{
use std::io::Write as _;
let _ = writeln!(f, "[{ts}] {}", message.as_ref());
}
}
/// Normalize the configured base URL to a canonical form used by the backend client.
/// - trims trailing '/'
/// - appends '/backend-api' for ChatGPT hosts when missing
pub fn normalize_base_url(input: &str) -> String {
let mut base_url = input.to_string();
while base_url.ends_with('/') {
base_url.pop();
}
if (base_url.starts_with("https://chatgpt.com")
|| base_url.starts_with("https://chat.openai.com"))
&& !base_url.contains("/backend-api")
{
base_url = format!("{base_url}/backend-api");
}
base_url
}
/// Extract the ChatGPT account id from a JWT token, when present.
pub fn extract_chatgpt_account_id(token: &str) -> Option<String> {
let mut parts = token.split('.');
let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
_ => return None,
};
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.ok()?;
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
v.get("https://api.openai.com/auth")
.and_then(|auth| auth.get("chatgpt_account_id"))
.and_then(|id| id.as_str())
.map(str::to_string)
}
/// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`,
/// and optional `ChatGPT-Account-Id`.
pub async fn build_chatgpt_headers() -> HeaderMap {
use reqwest::header::AUTHORIZATION;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
use reqwest::header::USER_AGENT;
set_user_agent_suffix("codex_cloud_tasks_tui");
let ua = codex_core::default_client::get_codex_user_agent(None);
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
);
if let Ok(home) = codex_core::config::find_codex_home() {
let am = codex_login::AuthManager::new(
home,
codex_login::AuthMode::ChatGPT,
codex_core::default_client::DEFAULT_ORIGINATOR.to_string(),
);
if let Some(auth) = am.auth()
&& let Ok(tok) = auth.get_token().await
&& !tok.is_empty()
{
let v = format!("Bearer {tok}");
if let Ok(hv) = HeaderValue::from_str(&v) {
headers.insert(AUTHORIZATION, hv);
}
if let Some(acc) = auth
.get_account_id()
.or_else(|| extract_chatgpt_account_id(&tok))
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
&& let Ok(hv) = HeaderValue::from_str(&acc)
{
headers.insert(name, hv);
}
}
}
headers
}
+22
View File
@@ -0,0 +1,22 @@
use codex_cloud_tasks_client::CloudBackend;
use codex_cloud_tasks_client::MockClient;
#[tokio::test]
async fn mock_backend_varies_by_env() {
let client = MockClient;
let root = CloudBackend::list_tasks(&client, None).await.unwrap();
assert!(root.iter().any(|t| t.title.contains("Update README")));
let a = CloudBackend::list_tasks(&client, Some("env-A"))
.await
.unwrap();
assert_eq!(a.len(), 1);
assert_eq!(a[0].title, "A: First");
let b = CloudBackend::list_tasks(&client, Some("env-B"))
.await
.unwrap();
assert_eq!(b.len(), 2);
assert!(b[0].title.starts_with("B: "));
}
@@ -0,0 +1,17 @@
[package]
name = "codex-backend-openapi-models"
version = { workspace = true }
edition = "2024"
[lib]
name = "codex_backend_openapi_models"
path = "src/lib.rs"
# Important: generated code often violates our workspace lints.
# Allow unwrap/expect in this crate so the workspace builds cleanly
# after models are regenerated.
# Lint overrides are applied in src/lib.rs via crate attributes
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -0,0 +1,6 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
// Re-export generated OpenAPI models.
// The regen script populates `src/models/*.rs` and writes `src/models/mod.rs`.
// This module intentionally contains no hand-written types.
pub mod models;
@@ -0,0 +1,42 @@
/*
* codex-backend
*
* codex-backend
*
* The version of the OpenAPI document: 0.0.1
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct CodeTaskDetailsResponse {
#[serde(rename = "task")]
pub task: Box<models::TaskResponse>,
#[serde(rename = "current_user_turn", skip_serializing_if = "Option::is_none")]
pub current_user_turn: Option<std::collections::HashMap<String, serde_json::Value>>,
#[serde(
rename = "current_assistant_turn",
skip_serializing_if = "Option::is_none"
)]
pub current_assistant_turn: Option<std::collections::HashMap<String, serde_json::Value>>,
#[serde(
rename = "current_diff_task_turn",
skip_serializing_if = "Option::is_none"
)]
pub current_diff_task_turn: Option<std::collections::HashMap<String, serde_json::Value>>,
}
impl CodeTaskDetailsResponse {
pub fn new(task: models::TaskResponse) -> CodeTaskDetailsResponse {
CodeTaskDetailsResponse {
task: Box::new(task),
current_user_turn: None,
current_assistant_turn: None,
current_diff_task_turn: None,
}
}
}
@@ -0,0 +1,40 @@
/*
* codex-backend
*
* codex-backend
*
* The version of the OpenAPI document: 0.0.1
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExternalPullRequestResponse {
#[serde(rename = "id")]
pub id: String,
#[serde(rename = "assistant_turn_id")]
pub assistant_turn_id: String,
#[serde(rename = "pull_request")]
pub pull_request: Box<models::GitPullRequest>,
#[serde(rename = "codex_updated_sha", skip_serializing_if = "Option::is_none")]
pub codex_updated_sha: Option<String>,
}
impl ExternalPullRequestResponse {
pub fn new(
id: String,
assistant_turn_id: String,
pull_request: models::GitPullRequest,
) -> ExternalPullRequestResponse {
ExternalPullRequestResponse {
id,
assistant_turn_id,
pull_request: Box::new(pull_request),
codex_updated_sha: None,
}
}
}
@@ -0,0 +1,77 @@
/*
* codex-backend
*
* codex-backend
*
* The version of the OpenAPI document: 0.0.1
*
* Generated by: https://openapi-generator.tech
*/
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct GitPullRequest {
#[serde(rename = "number")]
pub number: i32,
#[serde(rename = "url")]
pub url: String,
#[serde(rename = "state")]
pub state: String,
#[serde(rename = "merged")]
pub merged: bool,
#[serde(rename = "mergeable")]
pub mergeable: bool,
#[serde(rename = "draft", skip_serializing_if = "Option::is_none")]
pub draft: Option<bool>,
#[serde(rename = "title", skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(rename = "body", skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(rename = "base", skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
#[serde(rename = "head", skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
#[serde(rename = "base_sha", skip_serializing_if = "Option::is_none")]
pub base_sha: Option<String>,
#[serde(rename = "head_sha", skip_serializing_if = "Option::is_none")]
pub head_sha: Option<String>,
#[serde(rename = "merge_commit_sha", skip_serializing_if = "Option::is_none")]
pub merge_commit_sha: Option<String>,
#[serde(rename = "comments", skip_serializing_if = "Option::is_none")]
pub comments: Option<serde_json::Value>,
#[serde(rename = "diff", skip_serializing_if = "Option::is_none")]
pub diff: Option<serde_json::Value>,
#[serde(rename = "user", skip_serializing_if = "Option::is_none")]
pub user: Option<serde_json::Value>,
}
impl GitPullRequest {
pub fn new(
number: i32,
url: String,
state: String,
merged: bool,
mergeable: bool,
) -> GitPullRequest {
GitPullRequest {
number,
url,
state,
merged,
mergeable,
draft: None,
title: None,
body: None,
base: None,
head: None,
base_sha: None,
head_sha: None,
merge_commit_sha: None,
comments: None,
diff: None,
user: None,
}
}
}
@@ -0,0 +1,22 @@
// Curated minimal export list for current workspace usage.
// NOTE: This file was previously auto-generated by the OpenAPI generator.
// Currently export only the types referenced by the workspace
// The process for this will change
pub mod code_task_details_response;
pub use self::code_task_details_response::CodeTaskDetailsResponse;
pub mod task_response;
pub use self::task_response::TaskResponse;
pub mod external_pull_request_response;
pub use self::external_pull_request_response::ExternalPullRequestResponse;
pub mod git_pull_request;
pub use self::git_pull_request::GitPullRequest;
pub mod task_list_item;
pub use self::task_list_item::TaskListItem;
pub mod paginated_list_task_list_item_;
pub use self::paginated_list_task_list_item_::PaginatedListTaskListItem;
@@ -0,0 +1,30 @@
/*
* codex-backend
*
* codex-backend
*
* The version of the OpenAPI document: 0.0.1
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct PaginatedListTaskListItem {
#[serde(rename = "items")]
pub items: Vec<models::TaskListItem>,
#[serde(rename = "cursor", skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
impl PaginatedListTaskListItem {
pub fn new(items: Vec<models::TaskListItem>) -> PaginatedListTaskListItem {
PaginatedListTaskListItem {
items,
cursor: None,
}
}
}
@@ -0,0 +1,63 @@
/*
* codex-backend
*
* codex-backend
*
* The version of the OpenAPI document: 0.0.1
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct TaskListItem {
#[serde(rename = "id")]
pub id: String,
#[serde(rename = "title")]
pub title: String,
#[serde(
rename = "has_generated_title",
skip_serializing_if = "Option::is_none"
)]
pub has_generated_title: Option<bool>,
#[serde(rename = "updated_at", skip_serializing_if = "Option::is_none")]
pub updated_at: Option<f64>,
#[serde(rename = "created_at", skip_serializing_if = "Option::is_none")]
pub created_at: Option<f64>,
#[serde(
rename = "task_status_display",
skip_serializing_if = "Option::is_none"
)]
pub task_status_display: Option<std::collections::HashMap<String, serde_json::Value>>,
#[serde(rename = "archived")]
pub archived: bool,
#[serde(rename = "has_unread_turn")]
pub has_unread_turn: bool,
#[serde(rename = "pull_requests", skip_serializing_if = "Option::is_none")]
pub pull_requests: Option<Vec<models::ExternalPullRequestResponse>>,
}
impl TaskListItem {
pub fn new(
id: String,
title: String,
has_generated_title: Option<bool>,
archived: bool,
has_unread_turn: bool,
) -> TaskListItem {
TaskListItem {
id,
title,
has_generated_title,
updated_at: None,
created_at: None,
task_status_display: None,
archived,
has_unread_turn,
pull_requests: None,
}
}
}
@@ -0,0 +1,62 @@
/*
* codex-backend
*
* codex-backend
*
* The version of the OpenAPI document: 0.0.1
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct TaskResponse {
#[serde(rename = "id")]
pub id: String,
#[serde(rename = "created_at", skip_serializing_if = "Option::is_none")]
pub created_at: Option<f64>,
#[serde(rename = "title")]
pub title: String,
#[serde(
rename = "has_generated_title",
skip_serializing_if = "Option::is_none"
)]
pub has_generated_title: Option<bool>,
#[serde(rename = "current_turn_id", skip_serializing_if = "Option::is_none")]
pub current_turn_id: Option<String>,
#[serde(rename = "has_unread_turn", skip_serializing_if = "Option::is_none")]
pub has_unread_turn: Option<bool>,
#[serde(
rename = "denormalized_metadata",
skip_serializing_if = "Option::is_none"
)]
pub denormalized_metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
#[serde(rename = "archived")]
pub archived: bool,
#[serde(rename = "external_pull_requests")]
pub external_pull_requests: Vec<models::ExternalPullRequestResponse>,
}
impl TaskResponse {
pub fn new(
id: String,
title: String,
archived: bool,
external_pull_requests: Vec<models::ExternalPullRequestResponse>,
) -> TaskResponse {
TaskResponse {
id,
created_at: None,
title,
has_generated_title: None,
current_turn_id: None,
has_unread_turn: None,
denormalized_metadata: None,
archived,
external_pull_requests,
}
}
}
+1
View File
@@ -23,6 +23,7 @@ codex-file-search = { path = "../file-search" }
codex-mcp-client = { path = "../mcp-client" }
codex-protocol = { path = "../protocol" }
codex-rmcp-client = { path = "../rmcp-client" }
codex-otel = { path = "../otel", features = ["otel"] }
agent-client-protocol = "0.4.3"
dirs = "6"
env-flags = "0.1.1"
+11 -2
View File
@@ -849,8 +849,17 @@ impl AuthManager {
}
}
/// Convenience constructor returning an `Arc` wrapper.
pub fn shared(
/// Convenience constructor returning an `Arc` wrapper with default auth mode + originator.
pub fn shared(codex_home: PathBuf) -> Arc<Self> {
Arc::new(Self::new(
codex_home,
AuthMode::ApiKey,
crate::default_client::DEFAULT_ORIGINATOR.to_string(),
))
}
/// Convenience constructor returning an `Arc` wrapper with explicit auth mode and originator.
pub fn shared_with_mode_and_originator(
codex_home: PathBuf,
preferred_auth_mode: AuthMode,
originator: String,
+14 -2
View File
@@ -1,6 +1,7 @@
use std::time::Duration;
use bytes::Bytes;
use codex_otel::otel_event_manager::OtelEventManager;
use eventsource_stream::Eventsource;
use futures::Stream;
use futures::StreamExt;
@@ -41,6 +42,7 @@ pub(crate) async fn stream_chat_completions(
provider: &ModelProviderInfo,
debug_logger: &Arc<Mutex<DebugLogger>>,
auth_manager: Option<Arc<AuthManager>>,
otel_event_manager: Option<OtelEventManager>,
) -> Result<ResponseStream> {
if prompt.output_schema.is_some() {
return Err(CodexErr::UnsupportedOperation(
@@ -393,6 +395,7 @@ pub(crate) async fn stream_chat_completions(
provider.stream_idle_timeout(),
debug_logger_clone,
request_id_clone,
otel_event_manager.clone(),
));
return Ok(ResponseStream { rx_event });
}
@@ -461,6 +464,7 @@ async fn process_chat_sse<S>(
idle_timeout: Duration,
debug_logger: Arc<Mutex<DebugLogger>>,
request_id: String,
otel_event_manager: Option<OtelEventManager>,
) where
S: Stream<Item = Result<Bytes>> + Unpin,
{
@@ -485,7 +489,15 @@ async fn process_chat_sse<S>(
let mut current_item_id: Option<String> = None;
loop {
let sse = match timeout(idle_timeout, stream.next()).await {
let next_event = if let Some(manager) = otel_event_manager.as_ref() {
manager
.log_sse_event(|| timeout(idle_timeout, stream.next()))
.await
} else {
timeout(idle_timeout, stream.next()).await
};
let sse = match next_event {
Ok(Some(Ok(ev))) => ev,
Ok(Some(Err(e))) => {
let _ = tx_event
@@ -1039,7 +1051,7 @@ pub(crate) trait AggregateStreamExt: Stream<Item = Result<ResponseEvent>> + Size
/// `ResponseEvent` sequence for a typical text turn looks like:
///
/// ```ignore
/// OutputItemDone(<full message>)
/// OutputItemDone { item: <full message>, .. }
/// Completed
/// ```
///
+68 -15
View File
@@ -48,6 +48,7 @@ use crate::openai_tools::create_tools_json_for_responses_api;
use crate::protocol::RateLimitSnapshotEvent;
use crate::protocol::TokenUsage;
use crate::util::backoff;
use codex_otel::otel_event_manager::OtelEventManager;
use std::sync::Arc;
use std::sync::Mutex;
@@ -76,6 +77,7 @@ fn try_parse_retry_after(err: &Error) -> Option<std::time::Duration> {
pub struct ModelClient {
config: Arc<Config>,
auth_manager: Option<Arc<AuthManager>>,
otel_event_manager: Option<OtelEventManager>,
client: reqwest::Client,
provider: ModelProviderInfo,
session_id: Uuid,
@@ -89,6 +91,7 @@ impl ModelClient {
pub fn new(
config: Arc<Config>,
auth_manager: Option<Arc<AuthManager>>,
otel_event_manager: Option<OtelEventManager>,
provider: ModelProviderInfo,
effort: ReasoningEffortConfig,
summary: ReasoningSummaryConfig,
@@ -101,6 +104,7 @@ impl ModelClient {
Self {
config,
auth_manager,
otel_event_manager,
client,
provider,
session_id,
@@ -127,6 +131,10 @@ impl ModelClient {
self.verbosity
}
pub fn get_otel_event_manager(&self) -> Option<OtelEventManager> {
self.otel_event_manager.clone()
}
pub fn codex_home(&self) -> &Path {
&self.config.codex_home
}
@@ -169,6 +177,7 @@ impl ModelClient {
&self.provider,
&self.debug_logger,
self.auth_manager.clone(),
self.otel_event_manager.clone(),
)
.await?;
@@ -205,7 +214,8 @@ impl ModelClient {
if let Some(path) = &*CODEX_RS_SSE_FIXTURE {
// short circuit for tests
warn!(path, "Streaming from fixture");
return stream_from_fixture(path, self.provider.clone()).await;
return stream_from_fixture(path, self.provider.clone(), self.otel_event_manager.clone())
.await;
}
let auth_manager = self.auth_manager.clone();
@@ -385,7 +395,11 @@ impl ModelClient {
}
}
let res = req_builder.send().await;
let res = if let Some(otel) = self.otel_event_manager.as_ref() {
otel.log_request(attempt, || req_builder.send()).await
} else {
req_builder.send().await
};
if let Ok(resp) = &res {
trace!(
"Response status: {}, request-id: {}",
@@ -435,12 +449,14 @@ impl ModelClient {
let stream = resp.bytes_stream().map_err(CodexErr::Reqwest);
let debug_logger = Arc::clone(&self.debug_logger);
let request_id_clone = request_id.clone();
let otel_event_manager = self.otel_event_manager.clone();
tokio::spawn(process_sse(
stream,
tx_event,
self.provider.stream_idle_timeout(),
debug_logger,
request_id_clone,
otel_event_manager,
));
return Ok(ResponseStream { rx_event });
@@ -769,6 +785,7 @@ async fn process_sse<S>(
idle_timeout: Duration,
debug_logger: Arc<Mutex<DebugLogger>>,
request_id: String,
otel_event_manager: Option<OtelEventManager>,
) where
S: Stream<Item = Result<Bytes>> + Unpin,
{
@@ -793,7 +810,15 @@ async fn process_sse<S>(
let mut last_text_reasoning_content: HashMap<(String, u32, u32), String> = HashMap::new();
loop {
let sse = match timeout(idle_timeout, stream.next()).await {
let next_event = if let Some(manager) = otel_event_manager.as_ref() {
manager
.log_sse_event(|| timeout(idle_timeout, stream.next()))
.await
} else {
timeout(idle_timeout, stream.next()).await
};
let sse = match next_event {
Ok(Some(Ok(sse))) => sse,
Ok(Some(Err(e))) => {
debug!("SSE Error: {e:#}");
@@ -807,6 +832,21 @@ async fn process_sse<S>(
id: response_id,
usage,
}) => {
if let (Some(usage), Some(manager)) = (&usage, otel_event_manager.as_ref()) {
manager.sse_event_completed(
usage.input_tokens,
usage.output_tokens,
usage
.input_tokens_details
.as_ref()
.map(|d| d.cached_tokens),
usage
.output_tokens_details
.as_ref()
.map(|d| d.reasoning_tokens),
usage.total_tokens,
);
}
let event = ResponseEvent::Completed {
response_id,
token_usage: usage.map(Into::into),
@@ -814,12 +854,14 @@ async fn process_sse<S>(
let _ = tx_event.send(Ok(event)).await;
}
None => {
let _ = tx_event
.send(Err(response_error.unwrap_or(CodexErr::Stream(
"stream closed before response.completed".into(),
None,
))))
.await;
let error = response_error.unwrap_or(CodexErr::Stream(
"stream closed before response.completed".into(),
None,
));
if let Some(manager) = otel_event_manager.as_ref() {
manager.see_event_completed_failed(&error);
}
let _ = tx_event.send(Err(error)).await;
}
}
// Mark the request log as complete
@@ -1130,6 +1172,7 @@ async fn process_sse<S>(
async fn stream_from_fixture(
path: impl AsRef<Path>,
provider: ModelProviderInfo,
otel_event_manager: Option<OtelEventManager>,
) -> Result<ResponseStream> {
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
let f = std::fs::File::open(path.as_ref())?;
@@ -1152,6 +1195,7 @@ async fn stream_from_fixture(
provider.stream_idle_timeout(),
debug_logger,
String::new(), // Empty request_id for test fixture
otel_event_manager,
));
Ok(ResponseStream { rx_event })
}
@@ -1193,6 +1237,7 @@ mod tests {
provider.stream_idle_timeout(),
debug_logger,
String::new(),
None,
));
let mut events = Vec::new();
@@ -1230,6 +1275,7 @@ mod tests {
provider.stream_idle_timeout(),
debug_logger,
String::new(),
None,
));
let mut out = Vec::new();
@@ -1301,14 +1347,18 @@ mod tests {
matches!(
&events[0],
Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }))
if role == "assistant"
Ok(ResponseEvent::OutputItemDone {
item: ResponseItem::Message { role, .. },
..
}) if role == "assistant"
);
matches!(
&events[1],
Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }))
if role == "assistant"
Ok(ResponseEvent::OutputItemDone {
item: ResponseItem::Message { role, .. },
..
}) if role == "assistant"
);
match &events[2] {
@@ -1356,7 +1406,10 @@ mod tests {
assert_eq!(events.len(), 2);
matches!(events[0], Ok(ResponseEvent::OutputItemDone(_)));
matches!(
events[0],
Ok(ResponseEvent::OutputItemDone { .. })
);
match &events[1] {
Err(CodexErr::Stream(msg, _)) => {
@@ -1422,7 +1475,7 @@ mod tests {
matches!(ev, ResponseEvent::Created)
}
fn is_output(ev: &ResponseEvent) -> bool {
matches!(ev, ResponseEvent::OutputItemDone(_))
matches!(ev, ResponseEvent::OutputItemDone { .. })
}
fn is_completed(ev: &ResponseEvent) -> bool {
matches!(ev, ResponseEvent::Completed { .. })
+217 -70
View File
@@ -22,6 +22,13 @@ use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::MaybeApplyPatchVerified;
use codex_browser::BrowserConfig as CodexBrowserConfig;
use codex_browser::BrowserManager;
use codex_otel::otel_event_manager::OtelEventManager;
use codex_otel::otel_event_manager::ToolDecisionSource;
use codex_protocol::config_types::ReasoningEffort as ProtoReasoningEffort;
use codex_protocol::config_types::ReasoningSummary as ProtoReasoningSummary;
use codex_protocol::protocol::AskForApproval as ProtoAskForApproval;
use codex_protocol::protocol::ReviewDecision as ProtoReviewDecision;
use codex_protocol::protocol::SandboxPolicy as ProtoSandboxPolicy;
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::config_types::ClientTools;
@@ -120,6 +127,62 @@ impl ConfirmGuardPatternRuntime {
}
}
fn to_proto_reasoning_effort(effort: ReasoningEffortConfig) -> ProtoReasoningEffort {
match effort {
ReasoningEffortConfig::Minimal => ProtoReasoningEffort::Minimal,
ReasoningEffortConfig::Low => ProtoReasoningEffort::Low,
ReasoningEffortConfig::Medium => ProtoReasoningEffort::Medium,
ReasoningEffortConfig::High => ProtoReasoningEffort::High,
ReasoningEffortConfig::None => ProtoReasoningEffort::Minimal,
}
}
fn to_proto_reasoning_summary(summary: ReasoningSummaryConfig) -> ProtoReasoningSummary {
match summary {
ReasoningSummaryConfig::Auto => ProtoReasoningSummary::Auto,
ReasoningSummaryConfig::Concise => ProtoReasoningSummary::Concise,
ReasoningSummaryConfig::Detailed => ProtoReasoningSummary::Detailed,
ReasoningSummaryConfig::None => ProtoReasoningSummary::None,
}
}
fn to_proto_approval_policy(policy: AskForApproval) -> ProtoAskForApproval {
match policy {
AskForApproval::UnlessTrusted => ProtoAskForApproval::UnlessTrusted,
AskForApproval::OnFailure => ProtoAskForApproval::OnFailure,
AskForApproval::OnRequest => ProtoAskForApproval::OnRequest,
AskForApproval::Never => ProtoAskForApproval::Never,
}
}
fn to_proto_sandbox_policy(policy: SandboxPolicy) -> ProtoSandboxPolicy {
match policy {
SandboxPolicy::DangerFullAccess => ProtoSandboxPolicy::DangerFullAccess,
SandboxPolicy::ReadOnly => ProtoSandboxPolicy::ReadOnly,
SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
allow_git_writes: _,
} => ProtoSandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
},
}
}
fn to_proto_review_decision(decision: ReviewDecision) -> ProtoReviewDecision {
match decision {
ReviewDecision::Approved => ProtoReviewDecision::Approved,
ReviewDecision::ApprovedForSession => ProtoReviewDecision::ApprovedForSession,
ReviewDecision::Denied => ProtoReviewDecision::Denied,
ReviewDecision::Abort => ProtoReviewDecision::Abort,
}
}
#[allow(dead_code)]
trait MutexExt<T> {
fn lock_unchecked(&self) -> std::sync::MutexGuard<'_, T>;
@@ -1892,7 +1955,7 @@ impl Session {
&self.sandbox_policy,
with_escalated_permissions,
) {
SafetyCheck::AutoApprove { sandbox_type } => sandbox_type,
SafetyCheck::AutoApprove { sandbox_type, .. } => sandbox_type,
SafetyCheck::AskUser | SafetyCheck::Reject { .. } => {
crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None)
}
@@ -2931,10 +2994,44 @@ async fn submission_loop(
}
};
let conversation_id = codex_protocol::mcp_protocol::ConversationId::from(session_id);
let auth_snapshot = auth_manager.as_ref().and_then(|mgr| mgr.auth());
let otel_event_manager = {
let manager = OtelEventManager::new(
conversation_id,
config.model.as_str(),
config.model_family.slug.as_str(),
auth_snapshot
.as_ref()
.and_then(|auth| auth.get_account_id()),
auth_snapshot.as_ref().map(|auth| auth.mode),
config.otel.log_user_prompt,
crate::terminal::user_agent(),
);
manager.conversation_starts(
config.model_provider.name.as_str(),
Some(to_proto_reasoning_effort(model_reasoning_effort)),
to_proto_reasoning_summary(model_reasoning_summary),
config.model_context_window,
config.model_max_output_tokens,
config.model_auto_compact_token_limit,
to_proto_approval_policy(approval_policy),
to_proto_sandbox_policy(sandbox_policy.clone()),
config
.mcp_servers
.keys()
.map(String::as_str)
.collect(),
config.active_profile.clone(),
);
manager
};
// Wrap provided auth (if any) in a minimal AuthManager for client usage.
let client = ModelClient::new(
config.clone(),
auth_manager.clone(),
Some(otel_event_manager.clone()),
provider.clone(),
model_reasoning_effort,
model_reasoning_summary,
@@ -3448,9 +3545,15 @@ async fn spawn_review_thread(
}
};
let review_otel = parent_turn_context
.client
.get_otel_event_manager()
.map(|mgr| mgr.with_model(review_config.model.as_str(), review_config.model_family.slug.as_str()));
let review_client = ModelClient::new(
review_config.clone(),
parent_turn_context.client.get_auth_manager(),
review_otel,
parent_turn_context.client.get_provider(),
review_config.model_reasoning_effort,
review_config.model_reasoning_summary,
@@ -7403,6 +7506,8 @@ async fn handle_container_exec_with_params(
// If the argv is a shell wrapper, analyze and optionally strip `confirm:`.
let mut params = params;
let seq_hint_for_exec = seq_hint;
let otel_event_manager = sess.client.get_otel_event_manager();
let tool_name = "local_shell";
if let Some((script_index, script)) = extract_shell_script_from_wrapper(&params.command) {
let trimmed = script.trim_start();
let confirm_prefixes = ["confirm:", "CONFIRM:"];
@@ -7786,7 +7891,28 @@ async fn handle_container_exec_with_params(
let harness_summary_json: Option<String> = None;
let sandbox_type = match safety {
SafetyCheck::AutoApprove { sandbox_type } => sandbox_type,
SafetyCheck::AutoApprove {
sandbox_type,
user_explicitly_approved,
} => {
if let Some(manager) = otel_event_manager.as_ref() {
let (decision_for_log, source) = if user_explicitly_approved {
(
ReviewDecision::ApprovedForSession,
ToolDecisionSource::User,
)
} else {
(ReviewDecision::Approved, ToolDecisionSource::Config)
};
manager.tool_decision(
tool_name,
call_id.as_str(),
to_proto_review_decision(decision_for_log),
source,
);
}
sandbox_type
}
SafetyCheck::AskUser => {
let rx_approve = sess
.request_command_approval(
@@ -7797,8 +7923,19 @@ async fn handle_container_exec_with_params(
params.justification.clone(),
)
.await;
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved => (),
let decision = rx_approve.await.unwrap_or_default();
if let Some(manager) = otel_event_manager.as_ref() {
manager.tool_decision(
tool_name,
call_id.as_str(),
to_proto_review_decision(decision),
ToolDecisionSource::User,
);
}
match decision {
ReviewDecision::Approved => {}
ReviewDecision::ApprovedForSession => {
sess.add_approved_command(ApprovedCommandPattern::new(
params.command.clone(),
@@ -8058,6 +8195,8 @@ async fn handle_sandbox_error(
let call_id = exec_command_context.call_id.clone();
let sub_id = exec_command_context.sub_id.clone();
let cwd = exec_command_context.cwd.clone();
let otel_event_manager = sess.client.get_otel_event_manager();
let tool_name = "local_shell";
// Early out if either the user never wants to be asked for approval, or
// we're letting the model manage escalation requests. Otherwise, continue
@@ -8110,88 +8249,96 @@ async fn handle_sandbox_error(
)
.await;
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
let decision = rx_approve.await.unwrap_or_default();
if let Some(manager) = otel_event_manager.as_ref() {
manager.tool_decision(
tool_name,
call_id.as_str(),
to_proto_review_decision(decision),
ToolDecisionSource::User,
);
}
match decision {
ReviewDecision::Approved => {}
ReviewDecision::ApprovedForSession => {
// Persist this command as pre‑approved for the
// remainder of the session so future
// executions skip the sandbox directly.
// TODO(ragona): Isn't this a bug? It always saves the command in an | fork?
// remainder of the session so future executions skip the sandbox directly.
sess.add_approved_command(ApprovedCommandPattern::new(
params.command.clone(),
ApprovedCommandMatchKind::Exact,
None,
));
// Inform UI we are retrying without sandbox.
sess.notify_background_event(&sub_id, "retrying command without sandbox")
.await;
// This is an escalated retry; the policy will not be
// examined and the sandbox has been set to `None`.
// Use the same attempt_req as the tool call that failed; this retry
// is still part of the current provider attempt.
let retry_output_result = sess
.run_exec_with_events(
turn_diff_tracker,
exec_command_context.clone(),
ExecInvokeArgs {
params,
sandbox_type: SandboxType::None,
sandbox_policy: &sess.sandbox_policy,
sandbox_cwd: sess.get_cwd(),
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: if exec_command_context.apply_patch.is_some() {
None
} else {
Some(StdoutStream {
sub_id: sub_id.clone(),
call_id: call_id.clone(),
tx_event: sess.tx_event.clone(),
session: None,
tail_buf: None,
order: Some(crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index: None, sequence_number: None }),
})
},
},
None,
None,
attempt_req,
)
.await;
match retry_output_result {
Ok(retry_output) => {
let ExecToolCallOutput { exit_code, .. } = &retry_output;
let is_success = *exit_code == 0;
let content = format_exec_output_with_limit(sess, &sub_id, &call_id, &retry_output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content,
success: Some(is_success),
},
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: format!("retry failed: {e}"),
success: None,
},
},
}
}
ReviewDecision::Denied | ReviewDecision::Abort => {
// Fall through to original failure handling.
ResponseInputItem::FunctionCallOutput {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "exec command rejected by user".to_string(),
success: None,
},
};
}
};
// Inform UI we are retrying without sandbox.
sess.notify_background_event(&sub_id, "retrying command without sandbox")
.await;
// This is an escalated retry; the policy will not be examined and the sandbox has been set to `None`.
// Use the same attempt_req as the tool call that failed; this retry is still part of the current provider attempt.
let retry_output_result = sess
.run_exec_with_events(
turn_diff_tracker,
exec_command_context.clone(),
ExecInvokeArgs {
params,
sandbox_type: SandboxType::None,
sandbox_policy: &sess.sandbox_policy,
sandbox_cwd: sess.get_cwd(),
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: if exec_command_context.apply_patch.is_some() {
None
} else {
Some(StdoutStream {
sub_id: sub_id.clone(),
call_id: call_id.clone(),
tx_event: sess.tx_event.clone(),
session: None,
tail_buf: None,
order: Some(crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index: None, sequence_number: None }),
})
},
},
None,
None,
attempt_req,
)
.await;
match retry_output_result {
Ok(retry_output) => {
let ExecToolCallOutput { exit_code, .. } = &retry_output;
let is_success = *exit_code == 0;
let content = format_exec_output_with_limit(sess, &sub_id, &call_id, &retry_output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content,
success: Some(is_success),
},
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: format!("retry failed: {e}"),
success: None,
},
},
}
}
@@ -0,0 +1,99 @@
use crate::bash::parse_bash_lc_plain_commands;
pub fn command_might_be_dangerous(command: &[String]) -> bool {
if is_dangerous_to_call_with_exec(command) {
return true;
}
// Support `bash -lc "<script>"` where the any part of the script might contain a dangerous command.
if let Some(all_commands) = parse_bash_lc_plain_commands(command)
&& all_commands
.iter()
.any(|cmd| is_dangerous_to_call_with_exec(cmd))
{
return true;
}
false
}
fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
let cmd0 = command.first().map(String::as_str);
match cmd0 {
Some(cmd) if cmd.ends_with("git") || cmd.ends_with("/git") => {
matches!(command.get(1).map(String::as_str), Some("reset" | "rm"))
}
Some("rm") => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf")),
// for sudo <cmd> simply do the check for <cmd>
Some("sudo") => is_dangerous_to_call_with_exec(&command[1..]),
// ── anything else ─────────────────────────────────────────────────
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vec_str(items: &[&str]) -> Vec<String> {
items.iter().map(std::string::ToString::to_string).collect()
}
#[test]
fn git_reset_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&["git", "reset"])));
}
#[test]
fn bash_git_reset_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&[
"bash",
"-lc",
"git reset --hard"
])));
}
#[test]
fn git_status_is_not_dangerous() {
assert!(!command_might_be_dangerous(&vec_str(&["git", "status"])));
}
#[test]
fn bash_git_status_is_not_dangerous() {
assert!(!command_might_be_dangerous(&vec_str(&[
"bash",
"-lc",
"git status"
])));
}
#[test]
fn sudo_git_reset_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&[
"sudo", "git", "reset", "--hard"
])));
}
#[test]
fn usr_bin_git_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&[
"/usr/bin/git",
"reset",
"--hard"
])));
}
#[test]
fn rm_rf_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&["rm", "-rf", "/"])));
}
#[test]
fn rm_f_is_dangerous() {
assert!(command_might_be_dangerous(&vec_str(&["rm", "-f", "/"])));
}
}
+50 -1
View File
@@ -15,6 +15,9 @@ use crate::config_types::ThemeColors;
use crate::config_types::McpServerConfig;
use crate::config_types::McpServerTransportConfig;
use crate::config_types::Notifications;
use crate::config_types::OtelConfig;
use crate::config_types::OtelConfigToml;
use crate::config_types::OtelExporterKind;
use crate::config_types::ProjectCommandConfig;
use crate::config_types::ProjectHookConfig;
use crate::config_types::SandboxWorkspaceWrite;
@@ -24,6 +27,7 @@ use crate::config_types::TextVerbosity;
use crate::config_types::Tui;
use crate::config_types::UriBasedFileOpener;
use crate::config_types::ConfirmGuardConfig;
use crate::config_types::DEFAULT_OTEL_ENVIRONMENT;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::model_family::ModelFamily;
use crate::model_family::derive_default_model_family;
@@ -34,6 +38,8 @@ use crate::openai_model_info::get_model_info;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use crate::config_types::ReasoningEffort;
use std::sync::LazyLock;
use std::sync::Mutex;
use crate::config_types::ReasoningSummary;
use crate::project_features::{load_project_commands, ProjectCommand, ProjectHooks};
use codex_protocol::mcp_protocol::AuthMode;
@@ -69,6 +75,25 @@ const CONFIG_TOML_FILE: &str = "config.toml";
const DEFAULT_RESPONSES_ORIGINATOR_HEADER: &str = "codex_cli_rs";
static RESPONSES_ORIGINATOR_OVERRIDE: LazyLock<Mutex<Option<String>>> =
LazyLock::new(|| Mutex::new(None));
pub fn set_default_originator(originator: &str) -> std::io::Result<()> {
let mut guard = RESPONSES_ORIGINATOR_OVERRIDE
.lock()
.map_err(|_| std::io::Error::new(ErrorKind::Other, "originator override lock poisoned"))?;
*guard = Some(originator.to_string());
Ok(())
}
fn default_responses_originator() -> String {
RESPONSES_ORIGINATOR_OVERRIDE
.lock()
.ok()
.and_then(|guard| guard.clone())
.unwrap_or_else(|| DEFAULT_RESPONSES_ORIGINATOR_HEADER.to_owned())
}
/// Application configuration loaded from disk and merged with overrides.
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
@@ -130,6 +155,9 @@ pub struct Config {
/// who have opted into Zero Data Retention (ZDR).
pub disable_response_storage: bool,
/// OTEL configuration (exporter type, endpoint, headers, etc.).
pub otel: crate::config_types::OtelConfig,
/// When true, Code will silently install updates on startup whenever a newer
/// release is available. Upgrades are performed using the package manager
/// that originally installed the CLI (Homebrew or npm). Manual installs are
@@ -1535,6 +1563,9 @@ pub struct ConfigToml {
/// who have opted into Zero Data Retention (ZDR).
pub disable_response_storage: Option<bool>,
#[serde(default)]
pub otel: Option<OtelConfigToml>,
/// Enable silent upgrades during startup when a newer release is available.
#[serde(default, deserialize_with = "deserialize_option_bool_from_maybe_string")]
pub auto_upgrade_enabled: Option<bool>,
@@ -2048,7 +2079,7 @@ impl Config {
let responses_originator_header: String = cfg
.responses_originator_header_internal_override
.unwrap_or(DEFAULT_RESPONSES_ORIGINATOR_HEADER.to_owned());
.unwrap_or_else(|| default_responses_originator());
// Normalize agents: when `command` is missing/empty, default to `name`.
let agents: Vec<AgentConfig> = cfg
@@ -2168,6 +2199,19 @@ impl Config {
.map(|t| t.notifications.clone())
.unwrap_or_default(),
auto_drive_observer_cadence: cfg.auto_drive_observer_cadence.unwrap_or(5),
otel: {
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
let environment = t
.environment
.unwrap_or(DEFAULT_OTEL_ENVIRONMENT.to_string());
let exporter = t.exporter.unwrap_or(OtelExporterKind::None);
OtelConfig {
log_user_prompt,
environment,
exporter,
}
},
};
Ok(config)
}
@@ -2857,6 +2901,8 @@ model_verbosity = "high"
validation: ValidationConfig::default(),
experimental_resume: None,
tui_notifications: Default::default(),
auto_drive_observer_cadence: 5,
otel: crate::config_types::OtelConfig::default(),
},
o3_profile_config
);
@@ -2929,6 +2975,7 @@ model_verbosity = "high"
experimental_resume: None,
tui_notifications: Default::default(),
auto_drive_observer_cadence: 5,
otel: crate::config_types::OtelConfig::default(),
};
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
@@ -3015,6 +3062,7 @@ model_verbosity = "high"
experimental_resume: None,
tui_notifications: Default::default(),
auto_drive_observer_cadence: 5,
otel: crate::config_types::OtelConfig::default(),
};
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
@@ -3085,6 +3133,7 @@ model_verbosity = "high"
experimental_resume: None,
tui_notifications: Default::default(),
auto_drive_observer_cadence: 5,
otel: crate::config_types::OtelConfig::default(),
};
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);
+48
View File
@@ -16,6 +16,54 @@ use serde::Deserialize;
use serde::Serialize;
use strum_macros::Display;
pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
#[derive(Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum OtelHttpProtocol {
Binary,
Json,
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum OtelExporterKind {
None,
OtlpHttp {
endpoint: String,
headers: HashMap<String, String>,
protocol: OtelHttpProtocol,
},
OtlpGrpc {
endpoint: String,
headers: HashMap<String, String>,
},
}
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
pub struct OtelConfigToml {
pub log_user_prompt: Option<bool>,
pub environment: Option<String>,
pub exporter: Option<OtelExporterKind>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct OtelConfig {
pub log_user_prompt: bool,
pub environment: String,
pub exporter: OtelExporterKind,
}
impl Default for OtelConfig {
fn default() -> Self {
OtelConfig {
log_user_prompt: false,
environment: DEFAULT_OTEL_ENVIRONMENT.to_owned(),
exporter: OtelExporterKind::None,
}
}
}
#[derive(Serialize, Debug, Clone, PartialEq)]
pub struct McpServerConfig {
#[serde(flatten)]
+2
View File
@@ -67,6 +67,8 @@ pub async fn discover_prompts_in_excluding(
name,
path,
content,
description: None,
argument_hint: None,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
+2
View File
@@ -80,6 +80,7 @@ pub mod seatbelt;
pub mod shell;
pub mod spawn;
pub mod terminal;
pub mod otel_init;
mod tool_apply_patch;
mod workflow_validation;
pub mod turn_diff_tracker;
@@ -124,3 +125,4 @@ pub use codex_protocol::models::ReasoningItemContent;
pub use codex_protocol::models::ResponseItem;
pub use environment_context::ToolCandidate;
pub use environment_context::TOOL_CANDIDATES;
pub use otel_init::*;
+60
View File
@@ -0,0 +1,60 @@
use crate::config::Config;
use crate::config_types::OtelExporterKind as Kind;
use crate::config_types::OtelHttpProtocol as Protocol;
use codex_otel::config::OtelExporter;
use codex_otel::config::OtelHttpProtocol;
use codex_otel::config::OtelSettings;
use codex_otel::otel_provider::OtelProvider;
use std::error::Error;
/// Build an OpenTelemetry provider from the app Config.
///
/// Returns `None` when OTEL export is disabled.
pub fn build_provider(
config: &Config,
service_version: &str,
) -> Result<Option<OtelProvider>, Box<dyn Error>> {
let exporter = match &config.otel.exporter {
Kind::None => OtelExporter::None,
Kind::OtlpHttp {
endpoint,
headers,
protocol,
} => {
let protocol = match protocol {
Protocol::Json => OtelHttpProtocol::Json,
Protocol::Binary => OtelHttpProtocol::Binary,
};
OtelExporter::OtlpHttp {
endpoint: endpoint.clone(),
headers: headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
protocol,
}
}
Kind::OtlpGrpc { endpoint, headers } => OtelExporter::OtlpGrpc {
endpoint: endpoint.clone(),
headers: headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
},
};
OtelProvider::from(&OtelSettings {
service_name: config.responses_originator_header.clone(),
service_version: service_version.to_string(),
codex_home: config.codex_home.clone(),
environment: config.otel.environment.to_string(),
exporter,
})
}
/// Filter predicate for exporting only Codex-owned events via OTEL.
/// Keeps events that originated from codex_otel module
pub fn codex_export_filter(meta: &tracing::Metadata<'_>) -> bool {
meta.target().starts_with("codex_otel")
}
+26 -5
View File
@@ -14,7 +14,10 @@ use crate::protocol::SandboxPolicy;
#[derive(Debug, PartialEq)]
pub enum SafetyCheck {
AutoApprove { sandbox_type: SandboxType },
AutoApprove {
sandbox_type: SandboxType,
user_explicitly_approved: bool,
},
AskUser,
Reject { reason: String },
}
@@ -63,12 +66,16 @@ pub fn assess_patch_safety(
// fall back to asking the user because the patch may touch arbitrary
// paths outside the project.
match get_platform_sandbox() {
Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
Some(sandbox_type) => SafetyCheck::AutoApprove {
sandbox_type,
user_explicitly_approved: false,
},
None if sandbox_policy == &SandboxPolicy::DangerFullAccess => {
// If the user has explicitly requested DangerFullAccess, then
// we can auto-approve even without a sandbox.
SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None,
user_explicitly_approved: false,
}
}
None => SafetyCheck::AskUser,
@@ -112,8 +119,12 @@ pub fn assess_command_safety(
if is_known_safe_command(command)
|| approved.iter().any(|pattern| pattern.matches(command))
{
let user_explicitly_approved = approved
.iter()
.any(|pattern| pattern.matches(command));
return SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None,
user_explicitly_approved,
};
}
@@ -139,13 +150,17 @@ pub(crate) fn assess_safety_for_untrusted_command(
| (Never, DangerFullAccess)
| (OnRequest, DangerFullAccess) => SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None,
user_explicitly_approved: false,
},
(OnRequest, ReadOnly) | (OnRequest, WorkspaceWrite { .. }) => {
if with_escalated_permissions {
SafetyCheck::AskUser
} else {
match get_platform_sandbox() {
Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
Some(sandbox_type) => SafetyCheck::AutoApprove {
sandbox_type,
user_explicitly_approved: false,
},
// Fall back to asking since the command is untrusted and
// we do not have a sandbox available
None => SafetyCheck::AskUser,
@@ -157,7 +172,10 @@ pub(crate) fn assess_safety_for_untrusted_command(
| (OnFailure, ReadOnly)
| (OnFailure, WorkspaceWrite { .. }) => {
match get_platform_sandbox() {
Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
Some(sandbox_type) => SafetyCheck::AutoApprove {
sandbox_type,
user_explicitly_approved: false,
},
None => {
if matches!(approval_policy, OnFailure) {
// Since the command is not trusted, even though the
@@ -403,7 +421,10 @@ mod tests {
);
let expected = match get_platform_sandbox() {
Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
Some(sandbox_type) => SafetyCheck::AutoApprove {
sandbox_type,
user_explicitly_approved: false,
},
None => SafetyCheck::AskUser,
};
assert_eq!(safety_check, expected);
+9
View File
@@ -0,0 +1,9 @@
mod service;
mod session;
mod turn;
pub(crate) use service::SessionServices;
pub(crate) use session::SessionState;
pub(crate) use turn::ActiveTurn;
pub(crate) use turn::RunningTask;
pub(crate) use turn::TaskKind;
+18
View File
@@ -0,0 +1,18 @@
use crate::RolloutRecorder;
use crate::exec_command::ExecSessionManager;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::unified_exec::UnifiedExecSessionManager;
use crate::user_notification::UserNotifier;
use std::path::PathBuf;
use tokio::sync::Mutex;
pub(crate) struct SessionServices {
pub(crate) mcp_connection_manager: McpConnectionManager,
pub(crate) session_manager: ExecSessionManager,
pub(crate) unified_exec_manager: UnifiedExecSessionManager,
pub(crate) notifier: UserNotifier,
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) user_shell: crate::shell::Shell,
pub(crate) show_raw_agent_reasoning: bool,
}
+80
View File
@@ -0,0 +1,80 @@
//! Session-wide mutable state.
use std::collections::HashSet;
use codex_protocol::models::ResponseItem;
use crate::conversation_history::ConversationHistory;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::TokenUsage;
use crate::protocol::TokenUsageInfo;
/// Persistent, session-scoped state previously stored directly on `Session`.
#[derive(Default)]
pub(crate) struct SessionState {
pub(crate) approved_commands: HashSet<Vec<String>>,
pub(crate) history: ConversationHistory,
pub(crate) token_info: Option<TokenUsageInfo>,
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
}
impl SessionState {
/// Create a new session state mirroring previous `State::default()` semantics.
pub(crate) fn new() -> Self {
Self {
history: ConversationHistory::new(),
..Default::default()
}
}
// History helpers
pub(crate) fn record_items<I>(&mut self, items: I)
where
I: IntoIterator,
I::Item: std::ops::Deref<Target = ResponseItem>,
{
self.history.record_items(items)
}
pub(crate) fn history_snapshot(&self) -> Vec<ResponseItem> {
self.history.contents()
}
pub(crate) fn replace_history(&mut self, items: Vec<ResponseItem>) {
self.history.replace(items);
}
// Approved command helpers
pub(crate) fn add_approved_command(&mut self, cmd: Vec<String>) {
self.approved_commands.insert(cmd);
}
pub(crate) fn approved_commands_ref(&self) -> &HashSet<Vec<String>> {
&self.approved_commands
}
// Token/rate limit helpers
pub(crate) fn update_token_info_from_usage(
&mut self,
usage: &TokenUsage,
model_context_window: Option<u64>,
) {
self.token_info = TokenUsageInfo::new_or_append(
&self.token_info,
&Some(usage.clone()),
model_context_window,
);
}
pub(crate) fn set_rate_limits(&mut self, snapshot: RateLimitSnapshot) {
self.latest_rate_limits = Some(snapshot);
}
pub(crate) fn token_info_and_rate_limits(
&self,
) -> (Option<TokenUsageInfo>, Option<RateLimitSnapshot>) {
(self.token_info.clone(), self.latest_rate_limits.clone())
}
// Pending input/approval moved to TurnState.
}
+115
View File
@@ -0,0 +1,115 @@
//! Turn-scoped state and active turn metadata scaffolding.
use indexmap::IndexMap;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::task::AbortHandle;
use codex_protocol::models::ResponseInputItem;
use tokio::sync::oneshot;
use crate::protocol::ReviewDecision;
use crate::tasks::SessionTask;
/// Metadata about the currently running turn.
pub(crate) struct ActiveTurn {
pub(crate) tasks: IndexMap<String, RunningTask>,
pub(crate) turn_state: Arc<Mutex<TurnState>>,
}
impl Default for ActiveTurn {
fn default() -> Self {
Self {
tasks: IndexMap::new(),
turn_state: Arc::new(Mutex::new(TurnState::default())),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum TaskKind {
Regular,
Review,
Compact,
}
#[derive(Clone)]
pub(crate) struct RunningTask {
pub(crate) handle: AbortHandle,
pub(crate) kind: TaskKind,
pub(crate) task: Arc<dyn SessionTask>,
}
impl ActiveTurn {
pub(crate) fn add_task(&mut self, sub_id: String, task: RunningTask) {
self.tasks.insert(sub_id, task);
}
pub(crate) fn remove_task(&mut self, sub_id: &str) -> bool {
self.tasks.swap_remove(sub_id);
self.tasks.is_empty()
}
pub(crate) fn drain_tasks(&mut self) -> IndexMap<String, RunningTask> {
std::mem::take(&mut self.tasks)
}
}
/// Mutable state for a single turn.
#[derive(Default)]
pub(crate) struct TurnState {
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_input: Vec<ResponseInputItem>,
}
impl TurnState {
pub(crate) fn insert_pending_approval(
&mut self,
key: String,
tx: oneshot::Sender<ReviewDecision>,
) -> Option<oneshot::Sender<ReviewDecision>> {
self.pending_approvals.insert(key, tx)
}
pub(crate) fn remove_pending_approval(
&mut self,
key: &str,
) -> Option<oneshot::Sender<ReviewDecision>> {
self.pending_approvals.remove(key)
}
pub(crate) fn clear_pending(&mut self) {
self.pending_approvals.clear();
self.pending_input.clear();
}
pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) {
self.pending_input.push(input);
}
pub(crate) fn take_pending_input(&mut self) -> Vec<ResponseInputItem> {
if self.pending_input.is_empty() {
Vec::with_capacity(0)
} else {
let mut ret = Vec::new();
std::mem::swap(&mut ret, &mut self.pending_input);
ret
}
}
}
impl ActiveTurn {
/// Clear any pending approvals and input buffered for the current turn.
pub(crate) async fn clear_pending(&self) {
let mut ts = self.turn_state.lock().await;
ts.clear_pending();
}
/// Best-effort, non-blocking variant for synchronous contexts (Drop/interrupt).
pub(crate) fn try_clear_pending_sync(&self) {
if let Ok(mut ts) = self.turn_state.try_lock() {
ts.clear_pending();
}
}
}
+31
View File
@@ -0,0 +1,31 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::codex::TurnContext;
use crate::codex::compact;
use crate::protocol::InputItem;
use crate::state::TaskKind;
use super::SessionTask;
use super::SessionTaskContext;
#[derive(Clone, Copy, Default)]
pub(crate) struct CompactTask;
#[async_trait]
impl SessionTask for CompactTask {
fn kind(&self) -> TaskKind {
TaskKind::Compact
}
async fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) -> Option<String> {
compact::run_compact_task(session.clone_session(), ctx, sub_id, input).await
}
}
+166
View File
@@ -0,0 +1,166 @@
mod compact;
mod regular;
mod review;
use std::sync::Arc;
use async_trait::async_trait;
use tracing::trace;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::InputItem;
use crate::protocol::TaskCompleteEvent;
use crate::protocol::TurnAbortReason;
use crate::protocol::TurnAbortedEvent;
use crate::state::ActiveTurn;
use crate::state::RunningTask;
use crate::state::TaskKind;
pub(crate) use compact::CompactTask;
pub(crate) use regular::RegularTask;
pub(crate) use review::ReviewTask;
/// Thin wrapper that exposes the parts of [`Session`] task runners need.
#[derive(Clone)]
pub(crate) struct SessionTaskContext {
session: Arc<Session>,
}
impl SessionTaskContext {
pub(crate) fn new(session: Arc<Session>) -> Self {
Self { session }
}
pub(crate) fn clone_session(&self) -> Arc<Session> {
Arc::clone(&self.session)
}
}
#[async_trait]
pub(crate) trait SessionTask: Send + Sync + 'static {
fn kind(&self) -> TaskKind;
async fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) -> Option<String>;
async fn abort(&self, session: Arc<SessionTaskContext>, sub_id: &str) {
let _ = (session, sub_id);
}
}
impl Session {
pub async fn spawn_task<T: SessionTask>(
self: &Arc<Self>,
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
task: T,
) {
self.abort_all_tasks(TurnAbortReason::Replaced).await;
let task: Arc<dyn SessionTask> = Arc::new(task);
let task_kind = task.kind();
let handle = {
let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self)));
let ctx = Arc::clone(&turn_context);
let task_for_run = Arc::clone(&task);
let sub_clone = sub_id.clone();
tokio::spawn(async move {
let last_agent_message = task_for_run
.run(Arc::clone(&session_ctx), ctx, sub_clone.clone(), input)
.await;
// Emit completion uniformly from spawn site so all tasks share the same lifecycle.
let sess = session_ctx.clone_session();
sess.on_task_finished(sub_clone, last_agent_message).await;
})
.abort_handle()
};
let running_task = RunningTask {
handle,
kind: task_kind,
task,
};
self.register_new_active_task(sub_id, running_task).await;
}
pub async fn abort_all_tasks(self: &Arc<Self>, reason: TurnAbortReason) {
for (sub_id, task) in self.take_all_running_tasks().await {
self.handle_task_abort(sub_id, task, reason.clone()).await;
}
}
pub async fn on_task_finished(
self: &Arc<Self>,
sub_id: String,
last_agent_message: Option<String>,
) {
let mut active = self.active_turn.lock().await;
if let Some(at) = active.as_mut()
&& at.remove_task(&sub_id)
{
*active = None;
}
drop(active);
let event = Event {
id: sub_id,
msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }),
};
self.send_event(event).await;
}
async fn register_new_active_task(&self, sub_id: String, task: RunningTask) {
let mut active = self.active_turn.lock().await;
let mut turn = ActiveTurn::default();
turn.add_task(sub_id, task);
*active = Some(turn);
}
async fn take_all_running_tasks(&self) -> Vec<(String, RunningTask)> {
let mut active = self.active_turn.lock().await;
match active.take() {
Some(mut at) => {
at.clear_pending().await;
let tasks = at.drain_tasks();
tasks.into_iter().collect()
}
None => Vec::new(),
}
}
async fn handle_task_abort(
self: &Arc<Self>,
sub_id: String,
task: RunningTask,
reason: TurnAbortReason,
) {
if task.handle.is_finished() {
return;
}
trace!(task_kind = ?task.kind, sub_id, "aborting running task");
let session_task = task.task;
let handle = task.handle;
handle.abort();
let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self)));
session_task.abort(session_ctx, &sub_id).await;
let event = Event {
id: sub_id.clone(),
msg: EventMsg::TurnAborted(TurnAbortedEvent { reason }),
};
self.send_event(event).await;
}
}
#[cfg(test)]
mod tests {}
+32
View File
@@ -0,0 +1,32 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::codex::TurnContext;
use crate::codex::run_task;
use crate::protocol::InputItem;
use crate::state::TaskKind;
use super::SessionTask;
use super::SessionTaskContext;
#[derive(Clone, Copy, Default)]
pub(crate) struct RegularTask;
#[async_trait]
impl SessionTask for RegularTask {
fn kind(&self) -> TaskKind {
TaskKind::Regular
}
async fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) -> Option<String> {
let sess = session.clone_session();
run_task(sess, ctx, sub_id, input).await
}
}
+37
View File
@@ -0,0 +1,37 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::codex::TurnContext;
use crate::codex::exit_review_mode;
use crate::codex::run_task;
use crate::protocol::InputItem;
use crate::state::TaskKind;
use super::SessionTask;
use super::SessionTaskContext;
#[derive(Clone, Copy, Default)]
pub(crate) struct ReviewTask;
#[async_trait]
impl SessionTask for ReviewTask {
fn kind(&self) -> TaskKind {
TaskKind::Review
}
async fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) -> Option<String> {
let sess = session.clone_session();
run_task(sess, ctx, sub_id, input).await
}
async fn abort(&self, session: Arc<SessionTaskContext>, sub_id: &str) {
exit_review_mode(session.clone_session(), sub_id.to_string(), None).await;
}
}
@@ -91,6 +91,7 @@ where
let client = ModelClient::new(
Arc::clone(&config),
None,
None,
provider,
effort,
summary,
@@ -8,6 +8,7 @@ use codex_core::ResponseEvent;
use codex_core::ResponseItem;
use codex_core::WireApi;
use codex_core::debug_logger::DebugLogger;
use codex_otel::otel_event_manager::OtelEventManager;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use core_test_support::load_default_config_for_test;
use futures::StreamExt;
@@ -18,6 +19,7 @@ use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use codex_protocol::mcp_protocol::{AuthMode, ConversationId};
fn network_disabled() -> bool {
std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok()
@@ -70,6 +72,7 @@ async fn run_stream(sse_body: &str) -> Vec<ResponseEvent> {
let client = ModelClient::new(
Arc::clone(&config),
None,
None,
provider,
effort,
summary,
@@ -327,3 +330,98 @@ async fn streams_reasoning_before_tool_call() {
assert!(matches!(events[3], ResponseEvent::Completed { .. }));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn streams_with_otel_event_manager() {
if network_disabled() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
let server = MockServer::start().await;
let sse_body = concat!(
"data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1710000000,\"model\":\"gpt-4o-mini\",",
"\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1710000001,\"model\":\"gpt-4o-mini\",",
"\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n",
);
let template = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(sse_body, "text/event-stream");
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(template)
.expect(1)
.mount(&server)
.await;
let provider = ModelProviderInfo {
name: "openai".into(),
base_url: Some(server.uri()),
env_key: None,
env_key_instructions: None,
wire_api: WireApi::Chat,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(5_000),
requires_openai_auth: false,
openrouter: None,
};
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider_id = provider.name.clone();
config.model_provider = provider.clone();
config.show_raw_agent_reasoning = true;
let effort = config.model_reasoning_effort;
let summary = config.model_reasoning_summary;
let verbosity = config.model_text_verbosity;
let config = Arc::new(config);
let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap()));
let conversation_id = ConversationId::from(Uuid::new_v4());
let otel_manager = OtelEventManager::new(
conversation_id,
config.model.as_str(),
config.model_family.slug.as_str(),
None,
Some(AuthMode::ApiKey),
false,
"test-cli".to_string(),
);
let client = ModelClient::new(
Arc::clone(&config),
None,
Some(otel_manager),
provider,
effort,
summary,
verbosity,
Uuid::new_v4(),
debug_logger,
);
let mut prompt = Prompt::default();
prompt.input.push(ResponseItem::Message {
id: None,
role: "user".into(),
content: vec![ContentItem::InputText {
text: "hi".into(),
}],
});
let mut stream = client.stream(&prompt).await.expect("stream chat");
while let Some(event) = stream.next().await {
event.expect("event");
}
}
@@ -0,0 +1,40 @@
#![allow(clippy::expect_used)]
use std::path::Path;
use tempfile::TempDir;
use wiremock::MockServer;
pub struct TestCodexExecBuilder {
home: TempDir,
cwd: TempDir,
}
impl TestCodexExecBuilder {
pub fn cmd(&self) -> assert_cmd::Command {
let mut cmd = assert_cmd::Command::cargo_bin("codex-exec")
.expect("should find binary for codex-exec");
cmd.current_dir(self.cwd.path())
.env("CODEX_HOME", self.home.path())
.env("OPENAI_API_KEY", "dummy");
cmd
}
pub fn cmd_with_server(&self, server: &MockServer) -> assert_cmd::Command {
let mut cmd = self.cmd();
let base = format!("{}/v1", server.uri());
cmd.env("OPENAI_BASE_URL", base);
cmd
}
pub fn cwd_path(&self) -> &Path {
self.cwd.path()
}
pub fn home_path(&self) -> &Path {
self.home.path()
}
}
pub fn test_codex_exec() -> TestCodexExecBuilder {
TestCodexExecBuilder {
home: TempDir::new().expect("create temp home"),
cwd: TempDir::new().expect("create temp cwd"),
}
}
+14 -3
View File
@@ -1,5 +1,6 @@
use codex_core::CodexAuth;
use codex_core::ContentItem;
use codex_core::debug_logger::DebugLogger;
use codex_core::ConversationManager;
use codex_core::LocalShellAction;
use codex_core::LocalShellExecAction;
@@ -18,7 +19,6 @@ use codex_core::built_in_model_providers;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::models::WebSearchAction;
use core_test_support::load_default_config_for_test;
@@ -34,6 +34,7 @@ use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::sync::Arc;
use std::sync::Mutex;
use tempfile::TempDir;
use uuid::Uuid;
use wiremock::Mock;
@@ -766,15 +767,20 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
config.model_provider = provider.clone();
let effort = config.model_reasoning_effort;
let summary = config.model_reasoning_summary;
let verbosity = config.model_text_verbosity;
let config = Arc::new(config);
let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap()));
let client = ModelClient::new(
Arc::clone(&config),
None,
None,
provider,
effort,
summary,
ConversationId::new(),
verbosity,
Uuid::new_v4(),
debug_logger,
);
let mut prompt = Prompt::default();
@@ -1434,15 +1440,20 @@ async fn openrouter_metadata_is_forwarded_in_responses_payload() {
config.model_provider = provider.clone();
let effort = config.model_reasoning_effort;
let summary = config.model_reasoning_summary;
let verbosity = config.model_text_verbosity;
let config = Arc::new(config);
let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap()));
let client = ModelClient::new(
Arc::clone(&config),
None,
None,
provider,
effort,
summary,
ConversationId::new(),
verbosity,
Uuid::new_v4(),
debug_logger,
);
let mut prompt = Prompt::default();
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -38,15 +38,17 @@ See code for full type definitions and exact shapes: `protocol/src/mcp_protocol.
Run Codex as an MCP server and connect an MCP client:
```bash
codex mcp | your_mcp_client
codex mcp-server | your_mcp_client
```
For a simple inspection UI, you can also try:
```bash
npx @modelcontextprotocol/inspector codex mcp
npx @modelcontextprotocol/inspector codex mcp-server
```
Use the separate `codex mcp` subcommand to manage configured MCP server launchers in `config.toml`.
## Conversations
Start a new session with optional overrides:
+2
View File
@@ -40,6 +40,7 @@ tokio = { workspace = true, features = [
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
[dev-dependencies]
assert_cmd = { workspace = true }
core_test_support = { workspace = true }
@@ -49,3 +50,4 @@ tempfile = { workspace = true }
uuid = { workspace = true }
walkdir = { workspace = true }
wiremock = { workspace = true }
mcp-types = { workspace = true }
+24 -17
View File
@@ -2,16 +2,18 @@ use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
/// Top-level events emitted on the Codex Exec conversation stream.
/// Top-level events emitted on the Codex Exec thread stream.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(tag = "type")]
pub enum ConversationEvent {
#[serde(rename = "session.created")]
SessionCreated(SessionCreatedEvent),
pub enum ThreadEvent {
#[serde(rename = "thread.started")]
ThreadStarted(ThreadStartedEvent),
#[serde(rename = "turn.started")]
TurnStarted(TurnStartedEvent),
#[serde(rename = "turn.completed")]
TurnCompleted(TurnCompletedEvent),
#[serde(rename = "turn.failed")]
TurnFailed(TurnFailedEvent),
#[serde(rename = "item.started")]
ItemStarted(ItemStartedEvent),
#[serde(rename = "item.updated")]
@@ -19,12 +21,12 @@ pub enum ConversationEvent {
#[serde(rename = "item.completed")]
ItemCompleted(ItemCompletedEvent),
#[serde(rename = "error")]
Error(ConversationErrorEvent),
Error(ThreadErrorEvent),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct SessionCreatedEvent {
pub session_id: String,
pub struct ThreadStartedEvent {
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)]
@@ -35,6 +37,11 @@ pub struct TurnCompletedEvent {
pub usage: Usage,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct TurnFailedEvent {
pub error: ThreadErrorEvent,
}
/// Minimal usage summary for a turn.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)]
pub struct Usage {
@@ -45,37 +52,37 @@ pub struct Usage {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ItemStartedEvent {
pub item: ConversationItem,
pub item: ThreadItem,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ItemCompletedEvent {
pub item: ConversationItem,
pub item: ThreadItem,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ItemUpdatedEvent {
pub item: ConversationItem,
pub item: ThreadItem,
}
/// Fatal error emitted by the stream.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ConversationErrorEvent {
pub struct ThreadErrorEvent {
pub message: String,
}
/// Canonical representation of a conversation item and its domain-specific payload.
/// Canonical representation of a thread item and its domain-specific payload.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ConversationItem {
pub struct ThreadItem {
pub id: String,
#[serde(flatten)]
pub details: ConversationItemDetails,
pub details: ThreadItemDetails,
}
/// Typed payloads for each supported conversation item type.
/// Typed payloads for each supported thread item type.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(tag = "item_type", rename_all = "snake_case")]
pub enum ConversationItemDetails {
pub enum ThreadItemDetails {
AssistantMessage(AssistantMessageItem),
Reasoning(ReasoningItem),
CommandExecution(CommandExecutionItem),
@@ -86,7 +93,7 @@ pub enum ConversationItemDetails {
Error(ErrorItem),
}
/// Session conversation metadata.
/// Session metadata.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct SessionItem {
pub session_id: String,

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