diff --git a/.codespellrc b/.codespellrc index eefde42a4e..da831d8957 100644 --- a/.codespellrc +++ b/.codespellrc @@ -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 diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 3d49986791..ad16769471 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -25,4 +25,3 @@ jobs: uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2.1 with: ignore_words_file: .codespellignore - skip: frame*.txt diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml new file mode 100644 index 0000000000..0f3a7a194b --- /dev/null +++ b/.github/workflows/sdk.yml @@ -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/rust-toolchain@1.90 + + - 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 diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fc0eb20a88..49f4a148a8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -14,9 +14,9 @@ dependencies = [ [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] @@ -69,7 +69,7 @@ checksum = "fe233a377643e0fc1a56421d7c90acdec45c291b30345eb9f08e8d0ddce5a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -111,9 +111,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -141,35 +141,49 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +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 = "arboard" -version = "3.6.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f533f8e0af236ffe5eb979b99381df3258853f00ba2e44b6e1955292c75227" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", "image", @@ -181,7 +195,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "x11rb", ] @@ -232,7 +246,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -307,7 +321,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -318,7 +332,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -335,9 +349,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[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", @@ -353,8 +367,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "sync_wrapper", "tokio", "tower", @@ -364,9 +377,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", @@ -375,7 +388,6 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -383,9 +395,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.75" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -393,7 +405,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link 0.2.0", ] [[package]] @@ -440,9 +452,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "block-buffer" @@ -472,9 +484,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytemuck" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" [[package]] name = "byteorder" @@ -511,10 +523,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.30" +version = "1.2.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -526,9 +539,9 @@ checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "cfg_aliases" @@ -564,9 +577,9 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" [[package]] name = "clap" -version = "4.5.47" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" dependencies = [ "clap_builder", "clap_derive", @@ -574,9 +587,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.47" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" dependencies = [ "anstream", "anstyle", @@ -587,9 +600,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.57" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d9501bd3f5f09f7bbee01da9a511073ed30a80cd7a509f1214bb74eadea71ad" +checksum = "75bf0b32ad2e152de789bb635ea4d3078f6b838ad7974143e99b99f45a04af4a" dependencies = [ "clap", ] @@ -603,7 +616,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -636,6 +649,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "codex-app-server" +version = "0.0.0" +dependencies = [ + "anyhow", + "app_test_support", + "assert_cmd", + "base64", + "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", + "tracing", + "tracing-subscriber", + "uuid", + "wiremock", +] + [[package]] name = "codex-apply-patch" version = "0.0.0" @@ -663,6 +706,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-chatgpt" version = "0.0.0" @@ -671,6 +734,7 @@ dependencies = [ "clap", "codex-common", "codex-core", + "codex-git-apply", "serde", "serde_json", "tempfile", @@ -685,8 +749,10 @@ dependencies = [ "assert_cmd", "clap", "clap_complete", + "codex-app-server", "codex-arg0", "codex-chatgpt", + "codex-cloud-tasks", "codex-common", "codex-core", "codex-exec", @@ -695,6 +761,7 @@ dependencies = [ "codex-process-hardening", "codex-protocol", "codex-protocol-ts", + "codex-responses-api-proxy", "codex-tui", "ctor 0.5.0", "owo-colors", @@ -708,6 +775,48 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "codex-cloud-tasks" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "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" @@ -734,6 +843,7 @@ dependencies = [ "codex-apply-patch", "codex-file-search", "codex-mcp-client", + "codex-otel", "codex-protocol", "codex-rmcp-client", "core_test_support", @@ -742,7 +852,7 @@ dependencies = [ "escargot", "eventsource-stream", "futures", - "indexmap 2.10.0", + "indexmap 2.11.4", "landlock", "libc", "maplit", @@ -752,7 +862,7 @@ dependencies = [ "portable-pty", "predicates", "pretty_assertions", - "rand", + "rand 0.9.2", "regex-lite", "reqwest", "seccompiler", @@ -771,6 +881,7 @@ dependencies = [ "toml", "toml_edit", "tracing", + "tracing-test", "tree-sitter", "tree-sitter-bash", "uuid", @@ -795,6 +906,8 @@ dependencies = [ "codex-protocol", "core_test_support", "libc", + "mcp-types", + "opentelemetry-appender-tracing", "owo-colors", "predicates", "pretty_assertions", @@ -844,6 +957,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" @@ -877,7 +999,7 @@ dependencies = [ "codex-core", "codex-protocol", "core_test_support", - "rand", + "rand 0.9.2", "reqwest", "serde", "serde_json", @@ -888,6 +1010,7 @@ dependencies = [ "url", "urlencoding", "webbrowser", + "wiremock", ] [[package]] @@ -909,12 +1032,11 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", - "base64", "codex-arg0", "codex-common", "codex-core", - "codex-login", "codex-protocol", + "codex-utils-json-to-toml", "core_test_support", "mcp-types", "mcp_test_support", @@ -926,10 +1048,8 @@ dependencies = [ "shlex", "tempfile", "tokio", - "toml", "tracing", "tracing-subscriber", - "uuid", "wiremock", ] @@ -948,6 +1068,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" @@ -1051,11 +1191,12 @@ dependencies = [ "lazy_static", "libc", "mcp-types", + "opentelemetry-appender-tracing", "path-clean", "pathdiff", "pretty_assertions", "pulldown-cmark", - "rand", + "rand 0.9.2", "ratatui", "regex-lite", "serde", @@ -1077,6 +1218,15 @@ dependencies = [ "vt100", ] +[[package]] +name = "codex-utils-json-to-toml" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "serde_json", + "toml", +] + [[package]] name = "codex-utils-readiness" version = "0.0.0" @@ -1271,7 +1421,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "crossterm_winapi", "futures-core", "mio", @@ -1364,7 +1514,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1378,7 +1528,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1389,7 +1539,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1400,7 +1550,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1480,7 +1630,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "unicode-xid", ] @@ -1492,7 +1642,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "unicode-xid", ] @@ -1554,8 +1704,8 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.0", - "windows-sys 0.60.2", + "redox_users 0.5.2", + "windows-sys 0.61.1", ] [[package]] @@ -1575,7 +1725,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "objc2", ] @@ -1597,7 +1747,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1650,14 +1800,14 @@ checksum = "83e195b4945e88836d826124af44fdcb262ec01ef94d44f14f4fb5103f19892a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" @@ -1712,7 +1862,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1761,12 +1911,12 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.1", ] [[package]] @@ -1788,9 +1938,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -1851,7 +2001,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1861,7 +2011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.0.8", + "rustix 1.1.2", "windows-sys 0.59.0", ] @@ -1885,6 +2035,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + [[package]] name = "fixed_decimal" version = "0.7.0" @@ -1950,9 +2106,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -2013,7 +2169,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2067,19 +2223,19 @@ dependencies = [ [[package]] name = "gethostname" -version = "0.4.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" dependencies = [ - "libc", - "windows-targets 0.48.5", + "rustix 1.1.2", + "windows-targets 0.52.6", ] [[package]] name = "getopts" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ "unicode-width 0.2.1", ] @@ -2107,15 +2263,15 @@ dependencies = [ "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", ] [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "globset" @@ -2127,14 +2283,14 @@ dependencies = [ "bstr", "log", "regex-automata", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", ] [[package]] name = "h2" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", @@ -2142,7 +2298,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.10.0", + "indexmap 2.11.4", "slab", "tokio", "tokio-util", @@ -2177,15 +2333,21 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "heck" version = "0.5.0" @@ -2301,6 +2463,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -2308,6 +2471,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" @@ -2326,9 +2502,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64", "bytes", @@ -2342,7 +2518,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.0", "system-configuration", "tokio", "tower-service", @@ -2352,9 +2528,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2362,7 +2538,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.1", ] [[package]] @@ -2513,9 +2689,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2566,9 +2742,9 @@ dependencies = [ [[package]] name = "indenter" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" @@ -2583,13 +2759,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.16.0", "serde", + "serde_core", ] [[package]] @@ -2619,25 +2796,25 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "inventory" -version = "0.3.20" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab08d7cd2c5897f2c949e5383ea7c7db03fb19130ffcfbf7eda795137ae3cb83" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" dependencies = [ "rustversion", ] [[package]] name = "io-uring" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -2735,7 +2912,7 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2762,9 +2939,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -2803,9 +2980,9 @@ dependencies = [ [[package]] name = "landlock" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d2ef408b88e913bfc6594f5e693d57676f6463ded7d8bf994175364320c706" +checksum = "affe8b77dce5b172f8e290bd801b12832a77cd1942d1ea98259916e89d5829d6" dependencies = [ "enumflags2", "libc", @@ -2820,9 +2997,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "libm" @@ -2832,11 +3009,11 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.6" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "libc", ] @@ -2848,9 +3025,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -2903,7 +3080,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.15.4", + "hashbrown 0.15.5", ] [[package]] @@ -2963,7 +3140,6 @@ dependencies = [ "assert_cmd", "codex-core", "codex-mcp-server", - "codex-protocol", "mcp-types", "os_info", "pretty_assertions", @@ -2975,9 +3151,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memoffset" @@ -3063,7 +3239,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -3095,7 +3271,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -3107,7 +3283,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -3216,7 +3392,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -3228,7 +3404,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "dispatch2", "objc2", ] @@ -3239,7 +3415,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "dispatch2", "objc2", "objc2-core-foundation", @@ -3258,7 +3434,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", ] @@ -3269,16 +3445,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", ] [[package]] name = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] @@ -3301,7 +3477,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "foreign-types", "libc", @@ -3318,7 +3494,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3329,9 +3505,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" -version = "300.5.1+3.5.1" +version = "300.5.2+3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "735230c832b28c000e3bc117119e6466a663ec73506bc0a9907ea4187508e42a" +checksum = "d270b79e2926f5150189d475bc7e9d2c69f9c4697b185fa917d5a32b792d21b4" dependencies = [ "cc", ] @@ -3349,6 +3525,104 @@ 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-appender-tracing" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e68f63eca5fad47e570e00e893094fc17be959c80c79a7d6ec1abdd5ae6ffc16" +dependencies = [ + "opentelemetry", + "tracing", + "tracing-core", + "tracing-subscriber", +] + +[[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", + "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" @@ -3440,9 +3714,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petgraph" @@ -3451,7 +3725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.10.0", + "indexmap 2.11.4", ] [[package]] @@ -3463,6 +3737,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" @@ -3483,12 +3777,12 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plist" -version = "1.7.4" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64", - "indexmap 2.10.0", + "indexmap 2.11.4", "quick-xml", "serde", "time", @@ -3500,7 +3794,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "crc32fast", "fdeflate", "flate2", @@ -3545,9 +3839,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ "serde", "zerovec", @@ -3616,9 +3910,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -3630,20 +3924,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" dependencies = [ "futures", - "indexmap 2.10.0", + "indexmap 2.11.4", "nix 0.30.1", "tokio", "tracing", "windows", ] +[[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.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "getopts", "memchr", "pulldown-cmark-escape", @@ -3658,9 +3975,9 @@ checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" [[package]] name = "pxfm" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55f4fedc84ed39cb7a489322318976425e42a147e2be79d8f878e2884f94e84" +checksum = "83f9b339b02259ada5c0f4a389b7fb472f933aa17ce176fd2ad98f28bb401fde" dependencies = [ "num-traits", ] @@ -3673,18 +3990,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.0" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" dependencies = [ "memchr", ] [[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", @@ -3693,7 +4010,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.5.10", "thiserror 2.0.16", "tokio", "tracing", @@ -3702,14 +4019,14 @@ 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", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", @@ -3723,16 +4040,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.52.0", ] [[package]] @@ -3760,14 +4077,35 @@ dependencies = [ "nibble_vec", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -3777,7 +4115,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", ] [[package]] @@ -3794,7 +4141,7 @@ name = "ratatui" version = "0.29.0" source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cassowary", "compact_str", "crossterm", @@ -3811,11 +4158,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", ] [[package]] @@ -3831,9 +4178,9 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", @@ -3857,30 +4204,30 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", ] [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", ] [[package]] @@ -3897,9 +4244,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "reqwest" @@ -3929,6 +4276,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -3979,7 +4327,7 @@ dependencies = [ "paste", "pin-project-lite", "process-wrap", - "rand", + "rand 0.9.2", "reqwest", "rmcp-macros", "schemars 1.0.4", @@ -4005,14 +4353,14 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -4026,7 +4374,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4035,22 +4383,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.60.2", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.1", ] [[package]] name = "rustls" -version = "0.23.29" +version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ "once_cell", "ring", @@ -4060,6 +4408,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.3.0", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -4072,9 +4432,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.4" +version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ "ring", "rustls-pki-types", @@ -4083,9 +4443,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustyline" @@ -4093,7 +4453,7 @@ version = "14.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "clipboard-win", "fd-lock", @@ -4126,11 +4486,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -4222,7 +4582,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4234,7 +4594,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4258,7 +4618,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -4266,10 +4626,23 @@ dependencies = [ ] [[package]] -name = "security-framework-sys" -version = "2.14.0" +name = "security-framework" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -4277,9 +4650,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.226" +version = "1.0.227" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "80ece43fc6fbed4eb5392ab50c07334d3e577cbf40997ee896fe7af40bba4245" dependencies = [ "serde_core", "serde_derive", @@ -4287,22 +4660,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.227" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "7a576275b607a2c86ea29e410193df32bc680303c82f31e275bbfcafe8b33be5" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.227" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "51e694923b8824cf0e9b382adf0f60d4e05f348f357b38833a3fa5ed7c2ede04" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4313,7 +4686,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4322,7 +4695,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.11.4", "itoa", "memchr", "ryu", @@ -4338,16 +4711,16 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "serde_spanned" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -4364,15 +4737,15 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.14.0" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" dependencies = [ "base64", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.10.0", + "indexmap 2.11.4", "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -4384,21 +4757,21 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.0" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" dependencies = [ - "darling 0.20.11", + "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "serial2" -version = "0.2.31" +version = "0.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26e1e5956803a69ddd72ce2de337b577898801528749565def03515f82bad5bb" +checksum = "8cc76fa68e25e771492ca1e3c53d447ef0be3093e05cd3b47f4b712ba10c6f3c" dependencies = [ "cfg-if", "libc", @@ -4481,9 +4854,9 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -4530,6 +4903,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" @@ -4608,7 +4991,7 @@ dependencies = [ "dupe", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4710,7 +5093,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4722,7 +5105,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4753,9 +5136,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -4779,7 +5162,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4797,7 +5180,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4821,8 +5204,8 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.8", - "windows-sys 0.60.2", + "rustix 1.1.2", + "windows-sys 0.61.1", ] [[package]] @@ -4847,12 +5230,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.0.8", - "windows-sys 0.59.0", + "rustix 1.1.2", + "windows-sys 0.60.2", ] [[package]] @@ -4907,7 +5290,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4918,7 +5301,7 @@ checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4930,6 +5313,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" @@ -5038,7 +5431,7 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2", + "socket2 0.6.0", "tokio-macros", "windows-sys 0.59.0", ] @@ -5051,7 +5444,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5066,9 +5459,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -5113,12 +5506,12 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.5" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" dependencies = [ - "indexmap 2.10.0", - "serde", + "indexmap 2.11.4", + "serde_core", "serde_spanned", "toml_datetime", "toml_parser", @@ -5128,20 +5521,20 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" dependencies = [ - "serde", + "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.4" +version = "0.23.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" +checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.11.4", "toml_datetime", "toml_parser", "toml_writer", @@ -5150,18 +5543,47 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" +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", + "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" @@ -5171,11 +5593,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]] @@ -5184,7 +5610,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "bytes", "futures-util", "http", @@ -5240,7 +5666,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5293,14 +5719,35 @@ dependencies = [ ] [[package]] -name = "tree-sitter" -version = "0.25.9" +name = "tracing-test" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd2a058a86cfece0bf96f7cce1021efef9c8ed0e892ab74639173e5ed7a34fa" +checksum = "557b891436fe0d5e0e363427fc7f217abf9ccd510d5136549847bdcbcd011d68" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" dependencies = [ "cc", "regex", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", "serde_json", "streaming-iterator", "tree-sitter-language", @@ -5348,7 +5795,7 @@ checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "termcolor", ] @@ -5366,9 +5813,9 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-linebreak" @@ -5419,9 +5866,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -5534,44 +5981,54 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ - "wit-bindgen-rt", + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if", "js-sys", @@ -5582,9 +6039,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5592,22 +6049,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" dependencies = [ "unicode-ident", ] @@ -5627,9 +6084,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" dependencies = [ "js-sys", "wasm-bindgen", @@ -5712,11 +6169,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -5732,7 +6189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.61.2", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -5744,7 +6201,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core", + "windows-core 0.61.2", ] [[package]] @@ -5756,8 +6213,21 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.0", + "windows-result 0.4.0", + "windows-strings 0.5.0", ] [[package]] @@ -5766,31 +6236,31 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", "windows-threading", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5811,7 +6281,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", ] @@ -5822,8 +6292,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -5835,6 +6305,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -5844,6 +6323,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -5877,7 +6365,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.4", +] + +[[package]] +name = "windows-sys" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +dependencies = [ + "windows-link 0.2.0", ] [[package]] @@ -5895,21 +6392,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -5928,10 +6410,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" dependencies = [ + "windows-link 0.2.0", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -5957,12 +6440,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5981,12 +6458,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6005,12 +6476,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6041,12 +6506,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6065,12 +6524,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6089,12 +6542,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6113,12 +6560,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6133,9 +6574,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -6179,13 +6620,10 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" @@ -6195,20 +6633,20 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "x11rb" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 0.38.44", + "rustix 1.1.2", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "yansi" @@ -6236,28 +6674,28 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6277,7 +6715,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -6300,9 +6738,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", @@ -6317,7 +6755,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6328,9 +6766,9 @@ checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-jpeg" -version = "0.4.19" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9e525af0a6a658e031e95f14b7f889976b74a11ba0eca5a5fc9ac8a1c43a6a" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ "zune-core", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 862f06aa17..66d8073ca3 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -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,7 +44,9 @@ 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" } @@ -50,11 +60,14 @@ codex-login = { path = "login" } codex-mcp-client = { path = "mcp-client" } codex-mcp-server = { path = "mcp-server" } codex-ollama = { path = "ollama" } +codex-otel = { path = "otel" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } codex-protocol-ts = { path = "protocol-ts" } +codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } 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 +116,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 +159,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" diff --git a/codex-rs/README.md b/codex-rs/README.md index f51f746892..46eda63a1e 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -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. diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml new file mode 100644 index 0000000000..1cc25ef2d1 --- /dev/null +++ b/codex-rs/app-server/Cargo.toml @@ -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 } diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs similarity index 94% rename from codex-rs/mcp-server/src/codex_message_processor.rs rename to codex-rs/app-server/src/codex_message_processor.rs index eec3a62a2c..4d11aad421 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,6 +1,6 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; -use crate::json_to_toml::json_to_toml; +use crate::fuzzy_file_search::run_fuzzy_file_search; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; use codex_core::AuthManager; @@ -52,6 +52,8 @@ use codex_protocol::mcp_protocol::ExecArbitraryCommandResponse; use codex_protocol::mcp_protocol::ExecCommandApprovalParams; use codex_protocol::mcp_protocol::ExecCommandApprovalResponse; use codex_protocol::mcp_protocol::ExecOneOffCommandParams; +use codex_protocol::mcp_protocol::FuzzyFileSearchParams; +use codex_protocol::mcp_protocol::FuzzyFileSearchResponse; use codex_protocol::mcp_protocol::GetUserAgentResponse; use codex_protocol::mcp_protocol::GetUserSavedConfigResponse; use codex_protocol::mcp_protocol::GitDiffToRemoteResponse; @@ -74,6 +76,7 @@ use codex_protocol::mcp_protocol::SendUserMessageResponse; use codex_protocol::mcp_protocol::SendUserTurnParams; use codex_protocol::mcp_protocol::SendUserTurnResponse; use codex_protocol::mcp_protocol::ServerNotification; +use codex_protocol::mcp_protocol::SessionConfiguredNotification; use codex_protocol::mcp_protocol::SetDefaultModelParams; use codex_protocol::mcp_protocol::SetDefaultModelResponse; use codex_protocol::mcp_protocol::UserInfoResponse; @@ -82,12 +85,15 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InputMessageKind; use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use codex_utils_json_to_toml::json_to_toml; use mcp_types::JSONRPCErrorError; use mcp_types::RequestId; use std::collections::HashMap; use std::ffi::OsStr; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Duration; use tokio::select; use tokio::sync::Mutex; @@ -122,6 +128,7 @@ pub(crate) struct CodexMessageProcessor { active_login: Arc>>, // Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives. pending_interrupts: Arc>>>, + pending_fuzzy_searches: Arc>>>, } impl CodexMessageProcessor { @@ -141,11 +148,15 @@ impl CodexMessageProcessor { conversation_listeners: HashMap::new(), active_login: Arc::new(Mutex::new(None)), 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 @@ -206,6 +217,9 @@ impl CodexMessageProcessor { ClientRequest::UserInfo { request_id } => { self.get_user_info(request_id).await; } + ClientRequest::FuzzyFileSearch { request_id, params } => { + self.fuzzy_file_search(request_id, params).await; + } ClientRequest::ExecOneOffCommand { request_id, params } => { self.exec_one_off_command(request_id, params).await; } @@ -752,11 +766,19 @@ impl CodexMessageProcessor { session_configured, .. }) => { - let event = Event { - id: "".to_string(), - msg: EventMsg::SessionConfigured(session_configured.clone()), - }; - self.outgoing.send_event_as_notification(&event, None).await; + self.outgoing + .send_server_notification(ServerNotification::SessionConfigured( + SessionConfiguredNotification { + session_id: session_configured.session_id, + model: session_configured.model.clone(), + reasoning_effort: session_configured.reasoning_effort, + history_log_id: session_configured.history_log_id, + history_entry_count: session_configured.history_entry_count, + initial_messages: session_configured.initial_messages.clone(), + rollout_path: session_configured.rollout_path.clone(), + }, + )) + .await; let initial_messages = session_configured.initial_messages.map(|msgs| { msgs.into_iter() .filter(|event| { @@ -1167,6 +1189,46 @@ impl CodexMessageProcessor { } } } + + 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; + } } async fn apply_bespoke_event_handling( diff --git a/codex-rs/app-server/src/error_code.rs b/codex-rs/app-server/src/error_code.rs new file mode 100644 index 0000000000..1ffd889d40 --- /dev/null +++ b/codex-rs/app-server/src/error_code.rs @@ -0,0 +1,2 @@ +pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603; diff --git a/codex-rs/app-server/src/fuzzy_file_search.rs b/codex-rs/app-server/src/fuzzy_file_search.rs new file mode 100644 index 0000000000..eef760df26 --- /dev/null +++ b/codex-rs/app-server/src/fuzzy_file_search.rs @@ -0,0 +1,84 @@ +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; + +const LIMIT_PER_ROOT: usize = 50; +const MAX_THREADS: usize = 12; +const COMPUTE_INDICES: bool = true; + +pub(crate) async fn run_fuzzy_file_search( + query: String, + roots: Vec, + cancellation_flag: Arc, +) -> Vec { + #[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 = 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 +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs new file mode 100644 index 0000000000..a0c40db64a --- /dev/null +++ b/codex-rs/app-server/src/lib.rs @@ -0,0 +1,139 @@ +#![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; + +mod codex_message_processor; +mod error_code; +mod fuzzy_file_search; +mod message_processor; +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, + 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::(CHANNEL_CAPACITY); + let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); + + // 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::(&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(()) +} diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs new file mode 100644 index 0000000000..689ec0877a --- /dev/null +++ b/codex-rs/app-server/src/main.rs @@ -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(()) + }) +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs new file mode 100644 index 0000000000..24e0f2320a --- /dev/null +++ b/codex-rs/app-server/src/message_processor.rs @@ -0,0 +1,133 @@ +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::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, + codex_message_processor: CodexMessageProcessor, + 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, + config: Arc, + ) -> Self { + let outgoing = Arc::new(outgoing); + let auth_manager = AuthManager::shared(config.codex_home.clone()); + let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); + let codex_message_processor = CodexMessageProcessor::new( + auth_manager, + conversation_manager, + outgoing.clone(), + codex_linux_sandbox_exe, + config, + ); + + Self { + outgoing, + codex_message_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::(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(); + 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); + } +} diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs new file mode 100644 index 0000000000..25d5e7aa52 --- /dev/null +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; +use std::sync::atomic::AtomicI64; +use std::sync::atomic::Ordering; + +use codex_protocol::mcp_protocol::ServerNotification; +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(crate) struct OutgoingMessageSender { + next_request_id: AtomicI64, + sender: mpsc::UnboundedSender, + request_id_to_callback: Mutex>>, +} + +impl OutgoingMessageSender { + pub(crate) fn new(sender: mpsc::UnboundedSender) -> Self { + Self { + next_request_id: AtomicI64::new(0), + sender, + request_id_to_callback: Mutex::new(HashMap::new()), + } + } + + pub(crate) async fn send_request( + &self, + method: &str, + params: Option, + ) -> oneshot::Receiver { + let id = RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed)); + let outgoing_message_id = id.clone(); + let (tx_approve, rx_approve) = oneshot::channel(); + { + 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(crate) 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(crate) async fn send_response(&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; + } + } + } + + pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { + let _ = self + .sender + .send(OutgoingMessage::AppServerNotification(notification)); + } + + /// All notifications should be migrated to [`ServerNotification`] and + /// [`OutgoingMessage::Notification`] should be removed. + pub(crate) async fn send_notification(&self, notification: OutgoingNotification) { + let outgoing_message = OutgoingMessage::Notification(notification); + let _ = self.sender.send(outgoing_message); + } + + pub(crate) 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(crate) enum OutgoingMessage { + Request(OutgoingRequest), + Notification(OutgoingNotification), + /// AppServerNotification is specific to the case where this is run as an + /// "app server" as opposed to an MCP server. + AppServerNotification(ServerNotification), + Response(OutgoingResponse), + Error(OutgoingError), +} + +impl From 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, + }) + } + AppServerNotification(notification) => { + let method = notification.to_string(); + let params = match notification.to_params() { + Ok(params) => Some(params), + Err(err) => { + warn!("failed to serialize notification params: {err}"); + None + } + }; + 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(crate) struct OutgoingRequest { + pub id: RequestId, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingNotification { + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingResponse { + pub id: RequestId, + pub result: Result, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingError { + pub error: JSONRPCErrorError, + pub id: RequestId, +} + +#[cfg(test)] +mod tests { + use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification; + use pretty_assertions::assert_eq; + use serde_json::json; + use uuid::Uuid; + + use super::*; + + #[test] + fn verify_server_notification_serialization() { + let notification = + ServerNotification::LoginChatGptComplete(LoginChatGptCompleteNotification { + login_id: Uuid::nil(), + success: true, + error: None, + }); + + let jsonrpc_notification: JSONRPCMessage = + OutgoingMessage::AppServerNotification(notification).into(); + assert_eq!( + JSONRPCMessage::Notification(JSONRPCNotification { + jsonrpc: "2.0".into(), + method: "loginChatGptComplete".into(), + params: Some(json!({ + "loginId": Uuid::nil(), + "success": true, + })), + }), + jsonrpc_notification, + "ensure the strum macros serialize the method field correctly" + ); + } +} diff --git a/codex-rs/app-server/tests/all.rs b/codex-rs/app-server/tests/all.rs new file mode 100644 index 0000000000..7e136e4cce --- /dev/null +++ b/codex-rs/app-server/tests/all.rs @@ -0,0 +1,3 @@ +// Single integration test binary that aggregates all test modules. +// The submodules live in `tests/suite/`. +mod suite; diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml new file mode 100644 index 0000000000..4cc711fb6b --- /dev/null +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -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 } diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs new file mode 100644 index 0000000000..d088b184ea --- /dev/null +++ b/codex-rs/app-server/tests/common/lib.rs @@ -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(response: JSONRPCResponse) -> anyhow::Result { + let value = serde_json::to_value(response.result)?; + let codex_response = serde_json::from_value(value)?; + Ok(codex_response) +} diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs new file mode 100644 index 0000000000..cbf54cecf0 --- /dev/null +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -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, +} + +impl McpProcess { + pub async fn new(codex_home: &Path) -> anyhow::Result { + 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 { + // 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + self.send_request("getUserSavedConfig", None).await + } + + /// Send a `getUserAgent` JSON-RPC request. + pub async fn send_get_user_agent_request(&mut self) -> anyhow::Result { + self.send_request("getUserAgent", None).await + } + + /// Send a `userInfo` JSON-RPC request. + pub async fn send_user_info_request(&mut self) -> anyhow::Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + cancellation_token: Option, + ) -> anyhow::Result { + 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, + ) -> anyhow::Result { + 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 { + let mut line = String::new(); + self.stdout.read_line(&mut line).await?; + let message = serde_json::from_str::(&line)?; + eprintln!("read message from stdout: {message:?}"); + Ok(message) + } + + pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result { + 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 { + 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 { + 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 { + 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:?}"); + } + } + } + } +} diff --git a/codex-rs/app-server/tests/common/mock_model_server.rs b/codex-rs/app-server/tests/common/mock_model_server.rs new file mode 100644 index 0000000000..be7f3eb5b3 --- /dev/null +++ b/codex-rs/app-server/tests/common/mock_model_server.rs @@ -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) -> 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, +} + +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}"), + } + } +} diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs new file mode 100644 index 0000000000..9a827fb986 --- /dev/null +++ b/codex-rs/app-server/tests/common/responses.rs @@ -0,0 +1,95 @@ +use serde_json::json; +use std::path::Path; + +pub fn create_shell_sse_response( + command: Vec, + workdir: Option<&Path>, + timeout_ms: Option, + call_id: &str, +) -> anyhow::Result { + // 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 { + 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 { + // 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) +} diff --git a/codex-rs/mcp-server/tests/suite/archive_conversation.rs b/codex-rs/app-server/tests/suite/archive_conversation.rs similarity index 98% rename from codex-rs/mcp-server/tests/suite/archive_conversation.rs rename to codex-rs/app-server/tests/suite/archive_conversation.rs index e54a99896c..65a1589763 100644 --- a/codex-rs/mcp-server/tests/suite/archive_conversation.rs +++ b/codex-rs/app-server/tests/suite/archive_conversation.rs @@ -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; diff --git a/codex-rs/mcp-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs similarity index 99% rename from codex-rs/mcp-server/tests/suite/auth.rs rename to codex-rs/app-server/tests/suite/auth.rs index 6681fd7546..b19203d880 100644 --- a/codex-rs/mcp-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -1,12 +1,12 @@ use std::path::Path; +use app_test_support::McpProcess; +use app_test_support::to_response; use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::GetAuthStatusParams; use codex_protocol::mcp_protocol::GetAuthStatusResponse; use codex_protocol::mcp_protocol::LoginApiKeyParams; use codex_protocol::mcp_protocol::LoginApiKeyResponse; -use mcp_test_support::McpProcess; -use mcp_test_support::to_response; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; use pretty_assertions::assert_eq; diff --git a/codex-rs/mcp-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs similarity index 98% rename from codex-rs/mcp-server/tests/suite/codex_message_processor_flow.rs rename to codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 50a480b4a0..7527411223 100644 --- a/codex-rs/mcp-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -1,5 +1,10 @@ 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::create_shell_sse_response; +use app_test_support::to_response; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; @@ -16,11 +21,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; diff --git a/codex-rs/mcp-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs similarity index 98% rename from codex-rs/mcp-server/tests/suite/config.rs rename to codex-rs/app-server/tests/suite/config.rs index da64648c49..13cb6c4a2f 100644 --- a/codex-rs/mcp-server/tests/suite/config.rs +++ b/codex-rs/app-server/tests/suite/config.rs @@ -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; diff --git a/codex-rs/mcp-server/tests/suite/create_conversation.rs b/codex-rs/app-server/tests/suite/create_conversation.rs similarity index 96% rename from codex-rs/mcp-server/tests/suite/create_conversation.rs rename to codex-rs/app-server/tests/suite/create_conversation.rs index 1b62d01d46..d5896fffcb 100644 --- a/codex-rs/mcp-server/tests/suite/create_conversation.rs +++ b/codex-rs/app-server/tests/suite/create_conversation.rs @@ -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; diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs new file mode 100644 index 0000000000..12cbb7d574 --- /dev/null +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -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"); +} diff --git a/codex-rs/mcp-server/tests/suite/interrupt.rs b/codex-rs/app-server/tests/suite/interrupt.rs similarity index 95% rename from codex-rs/mcp-server/tests/suite/interrupt.rs rename to codex-rs/app-server/tests/suite/interrupt.rs index 232b695d1d..087f1b6078 100644 --- a/codex-rs/mcp-server/tests/suite/interrupt.rs +++ b/codex-rs/app-server/tests/suite/interrupt.rs @@ -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; @@ -17,10 +17,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); diff --git a/codex-rs/mcp-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs similarity index 87% rename from codex-rs/mcp-server/tests/suite/list_resume.rs rename to codex-rs/app-server/tests/suite/list_resume.rs index 9302b42990..9e91fdd9cd 100644 --- a/codex-rs/mcp-server/tests/suite/list_resume.rs +++ b/codex-rs/app-server/tests/suite/list_resume.rs @@ -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( diff --git a/codex-rs/mcp-server/tests/suite/login.rs b/codex-rs/app-server/tests/suite/login.rs similarity index 98% rename from codex-rs/mcp-server/tests/suite/login.rs rename to codex-rs/app-server/tests/suite/login.rs index 071154c643..475af9ab57 100644 --- a/codex-rs/mcp-server/tests/suite/login.rs +++ b/codex-rs/app-server/tests/suite/login.rs @@ -1,6 +1,8 @@ use std::path::Path; use std::time::Duration; +use app_test_support::McpProcess; +use app_test_support::to_response; use codex_login::login_with_api_key; use codex_protocol::mcp_protocol::CancelLoginChatGptParams; use codex_protocol::mcp_protocol::CancelLoginChatGptResponse; @@ -8,8 +10,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; diff --git a/codex-rs/app-server/tests/suite/mod.rs b/codex-rs/app-server/tests/suite/mod.rs new file mode 100644 index 0000000000..78ce310e74 --- /dev/null +++ b/codex-rs/app-server/tests/suite/mod.rs @@ -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; diff --git a/codex-rs/mcp-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs similarity index 97% rename from codex-rs/mcp-server/tests/suite/send_message.rs rename to codex-rs/app-server/tests/suite/send_message.rs index 158cb12d1c..81da0096f4 100644 --- a/codex-rs/mcp-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -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; diff --git a/codex-rs/mcp-server/tests/suite/set_default_model.rs b/codex-rs/app-server/tests/suite/set_default_model.rs similarity index 97% rename from codex-rs/mcp-server/tests/suite/set_default_model.rs rename to codex-rs/app-server/tests/suite/set_default_model.rs index f7e1041fa7..ce0d79d497 100644 --- a/codex-rs/mcp-server/tests/suite/set_default_model.rs +++ b/codex-rs/app-server/tests/suite/set_default_model.rs @@ -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; diff --git a/codex-rs/mcp-server/tests/suite/user_agent.rs b/codex-rs/app-server/tests/suite/user_agent.rs similarity index 91% rename from codex-rs/mcp-server/tests/suite/user_agent.rs rename to codex-rs/app-server/tests/suite/user_agent.rs index 718e145250..a7f75eab37 100644 --- a/codex-rs/mcp-server/tests/suite/user_agent.rs +++ b/codex-rs/app-server/tests/suite/user_agent.rs @@ -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"), diff --git a/codex-rs/mcp-server/tests/suite/user_info.rs b/codex-rs/app-server/tests/suite/user_info.rs similarity index 97% rename from codex-rs/mcp-server/tests/suite/user_info.rs rename to codex-rs/app-server/tests/suite/user_info.rs index 7bcb2acc6b..10ca7d330a 100644 --- a/codex-rs/mcp-server/tests/suite/user_info.rs +++ b/codex-rs/app-server/tests/suite/user_info.rs @@ -1,6 +1,8 @@ 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; @@ -9,8 +11,6 @@ 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_test_support::McpProcess; -use mcp_test_support::to_response; use mcp_types::JSONRPCResponse; use mcp_types::RequestId; use pretty_assertions::assert_eq; diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml new file mode 100644 index 0000000000..a8e2ee85fb --- /dev/null +++ b/codex-rs/backend-client/Cargo.toml @@ -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" diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs new file mode 100644 index 0000000000..06ad00cb80 --- /dev/null +++ b/codex-rs/backend-client/src/client.rs @@ -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, + user_agent: Option, + chatgpt_account_id: Option, + path_style: PathStyle, +} + +impl Client { + pub fn new(base_url: impl Into) -> Result { + 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) -> Self { + self.bearer_token = Some(token.into()); + self + } + + pub fn with_user_agent(mut self, ua: impl Into) -> 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) -> 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(&self, url: &str, ct: &str, body: &str) -> Result { + match serde_json::from_str::(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, + task_filter: Option<&str>, + environment_id: Option<&str>, + ) -> Result { + 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::(&url, &ct, &body) + } + + pub async fn get_task_details(&self, task_id: &str) -> Result { + 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 { + 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::(&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 { + 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::(&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}"), + } + } +} diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs new file mode 100644 index 0000000000..29fe9f3c6b --- /dev/null +++ b/codex-rs/backend-client/src/lib.rs @@ -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; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs new file mode 100644 index 0000000000..8e2dfb2126 --- /dev/null +++ b/codex-rs/backend-client/src/types.rs @@ -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, + #[serde(default)] + pub current_assistant_turn: Option, + #[serde(default)] + pub current_diff_task_turn: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Turn { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub attempt_placement: Option, + #[serde(default, rename = "turn_status")] + pub turn_status: Option, + #[serde(default, deserialize_with = "deserialize_vec")] + pub sibling_turn_ids: Vec, + #[serde(default, deserialize_with = "deserialize_vec")] + pub input_items: Vec, + #[serde(default, deserialize_with = "deserialize_vec")] + pub output_items: Vec, + #[serde(default)] + pub worklog: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct TurnItem { + #[serde(rename = "type", default)] + pub kind: String, + #[serde(default)] + pub role: Option, + #[serde(default, deserialize_with = "deserialize_vec")] + pub content: Vec, + #[serde(default)] + pub diff: Option, + #[serde(default)] + pub output_diff: Option, +} + +#[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, + #[serde(default)] + pub text: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct DiffPayload { + #[serde(default)] + pub diff: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Worklog { + #[serde(default, deserialize_with = "deserialize_vec")] + pub messages: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct WorklogMessage { + #[serde(default)] + pub author: Option, + #[serde(default)] + pub content: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Author { + #[serde(default)] + pub role: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct WorklogContent { + #[serde(default)] + pub parts: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct TurnError { + #[serde(default)] + pub code: Option, + #[serde(default)] + pub message: Option, +} + +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 { + self.content + .iter() + .filter_map(|fragment| fragment.text().map(str::to_string)) + .collect() + } + + fn diff_text(&self) -> Option { + 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 { + self.output_items.iter().find_map(TurnItem::diff_text) + } + + fn message_texts(&self) -> Vec { + let mut out: Vec = 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 { + let parts: Vec = 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 { + 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 { + 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 { + 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; + /// Extract assistant text output messages (no diff) from current turns. + fn assistant_text_messages(&self) -> Vec; + /// Extract the user's prompt text from the current user turn, when present. + fn user_text_prompt(&self) -> Option; + /// Extract an assistant error message (if the turn failed and provided one). + fn assistant_error_message(&self) -> Option; +} + +impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse { + fn unified_diff(&self) -> Option { + [ + 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 { + 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 { + self.current_user_turn.as_ref().and_then(Turn::user_prompt) + } + + fn assistant_error_message(&self) -> Option { + self.current_assistant_turn + .as_ref() + .and_then(Turn::error_summary) + } +} + +fn deserialize_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::>::deserialize(deserializer).map(|opt| opt.unwrap_or_default()) +} + +#[derive(Clone, Debug, Deserialize)] +pub struct TurnAttemptsSiblingTurnsResponse { + #[serde(default)] + pub sibling_turns: Vec>, +} + +#[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"); + } +} diff --git a/codex-rs/backend-client/tests/fixtures/task_details_with_diff.json b/codex-rs/backend-client/tests/fixtures/task_details_with_diff.json new file mode 100644 index 0000000000..3a06b04c1c --- /dev/null +++ b/codex-rs/backend-client/tests/fixtures/task_details_with_diff.json @@ -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" + } + ] + } +} diff --git a/codex-rs/backend-client/tests/fixtures/task_details_with_error.json b/codex-rs/backend-client/tests/fixtures/task_details_with_error.json new file mode 100644 index 0000000000..6f6b66a72c --- /dev/null +++ b/codex-rs/backend-client/tests/fixtures/task_details_with_error.json @@ -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" + } + } +} diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 97e14d7fe7..0a3170865a 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -14,6 +14,7 @@ codex-core = { 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 } diff --git a/codex-rs/chatgpt/src/apply_command.rs b/codex-rs/chatgpt/src/apply_command.rs index 52ab205a0c..656197f767 100644 --- a/codex-rs/chatgpt/src/apply_command.rs +++ b/codex-rs/chatgpt/src/apply_command.rs @@ -56,46 +56,24 @@ pub async fn apply_diff_from_task( } async fn apply_diff(diff: &str, cwd: Option) -> 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(()) } diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index a4545f075e..d450f7c59e 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -44,6 +44,6 @@ pub(crate) async fn chatgpt_get_request( } 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}") } } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 618f4b7b03..c62a267c73 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -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"] } @@ -28,7 +29,9 @@ codex-mcp-server = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } codex-protocol-ts = { workspace = true } +codex-responses-api-proxy = { workspace = true } codex-tui = { workspace = true } +codex-cloud-tasks = { path = "../cloud-tasks" } ctor = { workspace = true } owo-colors = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index f0816d0b29..8dd4fb8333 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -6,6 +6,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::path::PathBuf; @@ -55,6 +56,32 @@ 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, + client_id: Option, +) -> ! { + 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()), + ); + 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); diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index ca248229c7..b99577d092 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -10,10 +10,13 @@ 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; +use codex_cloud_tasks::Cli as CloudTasksCli; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; +use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use owo_colors::OwoColorize; @@ -65,6 +68,12 @@ enum Subcommand { /// [experimental] Run Codex as an MCP server and manage MCP servers. 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), @@ -85,6 +94,13 @@ 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), + + /// Internal: run the responses API proxy. + #[clap(hide = true)] + ResponsesApiProxy(ResponsesApiProxyArgs), } #[derive(Debug, Parser)] @@ -132,6 +148,20 @@ struct LoginCommand { #[arg(long = "api-key", value_name = "API_KEY")] api_key: Option, + /// 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, + + /// EXPERIMENTAL: Use custom OAuth client ID (advanced) + #[arg(long = "experimental_client-id", value_name = "CLIENT_ID", hide = true)] + client_id: Option, + #[command(subcommand)] action: Option, } @@ -245,10 +275,16 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> 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, @@ -274,7 +310,14 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> 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; @@ -299,6 +342,13 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> 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( @@ -330,6 +380,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ); run_apply_command(apply_cli, None).await?; } + Some(Subcommand::ResponsesApiProxy(args)) => { + tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) + .await??; + } Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 0cb448d8be..85243a641b 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -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, + 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) -> 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)?; } diff --git a/codex-rs/cloud-tasks-client/Cargo.toml b/codex-rs/cloud-tasks-client/Cargo.toml new file mode 100644 index 0000000000..ca45b6e15b --- /dev/null +++ b/codex-rs/cloud-tasks-client/Cargo.toml @@ -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" } diff --git a/codex-rs/cloud-tasks-client/src/api.rs b/codex-rs/cloud-tasks-client/src/api.rs new file mode 100644 index 0000000000..4bd12939e8 --- /dev/null +++ b/codex-rs/cloud-tasks-client/src/api.rs @@ -0,0 +1,158 @@ +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; + +pub type Result = std::result::Result; + +#[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, + /// Backend environment identifier (when available) + pub environment_id: Option, + /// Human-friendly environment label (when available) + pub environment_label: Option, + 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, +} + +#[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, + pub created_at: Option>, + pub status: AttemptStatus, + pub diff: Option, + pub messages: Vec, +} + +#[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, + #[serde(default)] + pub conflict_paths: Vec, +} + +#[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, + pub messages: Vec, + pub turn_id: Option, + pub sibling_turn_ids: Vec, + pub attempt_placement: Option, + 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>; + async fn get_task_diff(&self, id: TaskId) -> Result>; + /// Return assistant output messages (no diff) when available. + async fn get_task_messages(&self, id: TaskId) -> Result>; + /// Return the creating prompt and assistant messages (when available). + async fn get_task_text(&self, id: TaskId) -> Result; + /// Return any sibling attempts (best-of-N) for the given assistant turn. + async fn list_sibling_attempts( + &self, + task: TaskId, + turn_id: String, + ) -> Result>; + /// 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, + ) -> Result; + async fn apply_task(&self, id: TaskId, diff_override: Option) -> Result; + async fn create_task( + &self, + env_id: &str, + prompt: &str, + git_ref: &str, + qa_mode: bool, + best_of_n: usize, + ) -> Result; +} diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs new file mode 100644 index 0000000000..912681cd3b --- /dev/null +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -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) -> anyhow::Result { + 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) -> Self { + self.backend = self.backend.clone().with_bearer_token(token); + self + } + + pub fn with_user_agent(mut self, ua: impl Into) -> Self { + self.backend = self.backend.clone().with_user_agent(ua); + self + } + + pub fn with_chatgpt_account_id(mut self, account_id: impl Into) -> 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> { + self.tasks_api().list(env).await + } + + async fn get_task_diff(&self, id: TaskId) -> Result> { + self.tasks_api().diff(id).await + } + + async fn get_task_messages(&self, id: TaskId) -> Result> { + self.tasks_api().messages(id).await + } + + async fn get_task_text(&self, id: TaskId) -> Result { + self.tasks_api().task_text(id).await + } + + async fn list_sibling_attempts( + &self, + task: TaskId, + turn_id: String, + ) -> Result> { + self.attempts_api().list(task, turn_id).await + } + + async fn apply_task(&self, id: TaskId, diff_override: Option) -> Result { + self.apply_api().run(id, diff_override, false).await + } + + async fn apply_task_preflight( + &self, + id: TaskId, + diff_override: Option, + ) -> Result { + 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 { + 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> { + 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 = resp + .items + .into_iter() + .map(map_task_list_item_to_summary) + .collect(); + + append_error_log(&format!( + "http.list_tasks: env={} items={}", + env.unwrap_or(""), + tasks.len() + )); + Ok(tasks) + } + + pub(crate) async fn diff(&self, id: TaskId) -> Result> { + 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> { + 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 { + 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 { + let mut input_items: Vec = 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> { + 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 = 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, + preflight: bool, + ) -> Result { + 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 { + 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 { + let mut msgs = Vec::new(); + if let Ok(full) = serde_json::from_str::(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) -> Option { + 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) -> Option { + 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) -> Vec { + 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> { + 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::::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>) -> 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 { + 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::::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>) -> Option { + 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>) -> 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>) -> Option { + 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(|| "".to_string()); + let head: String = patch.lines().take(20).collect::>().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}"); + } +} diff --git a/codex-rs/cloud-tasks-client/src/lib.rs b/codex-rs/cloud-tasks-client/src/lib.rs new file mode 100644 index 0000000000..1c5a01e2aa --- /dev/null +++ b/codex-rs/cloud-tasks-client/src/lib.rs @@ -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`. diff --git a/codex-rs/cloud-tasks-client/src/mock.rs b/codex-rs/cloud-tasks-client/src/mock.rs new file mode 100644 index 0000000000..97bc5520a8 --- /dev/null +++ b/codex-rs/cloud-tasks-client/src/mock.rs @@ -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> { + // 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> { + Ok(Some(mock_diff_for(&id))) + } + + async fn get_task_messages(&self, _id: TaskId) -> Result> { + Ok(vec![ + "Mock assistant output: this task contains no diff.".to_string(), + ]) + } + + async fn get_task_text(&self, _id: TaskId) -> Result { + 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) -> Result { + 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, + ) -> Result { + 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> { + 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 { + 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) + } +} diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml new file mode 100644 index 0000000000..d0cee3ff1a --- /dev/null +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -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" diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs new file mode 100644 index 0000000000..adf17419ee --- /dev/null +++ b/codex-rs/cloud-tasks/src/app.rs @@ -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, + pub is_pinned: bool, + pub repo_hints: Option, // 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, + pub result_level: Option, + pub skipped_paths: Vec, + pub conflict_paths: Vec, + pub diff_override: Option, +} + +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, + pub selected: usize, + pub status: String, + pub diff_overlay: Option, + pub throbber: ThrobberState, + pub refresh_inflight: bool, + pub details_inflight: bool, + // Environment filter state + pub env_filter: Option, + pub env_modal: Option, + pub apply_modal: Option, + pub best_of_modal: Option, + pub environments: Vec, + pub env_last_loaded: Option, + pub env_loading: bool, + pub env_error: Option, + // New Task page + pub new_task: Option, + 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, + // 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> { + // 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 = 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, + pub text_lines: Vec, + pub prompt: Option, + pub attempts: Vec, + pub selected_attempt: usize, + pub current_view: DetailView, + pub base_turn_id: Option, + pub sibling_turn_ids: Vec, + pub attempt_total_hint: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct AttemptView { + pub turn_id: Option, + pub status: codex_cloud_tasks_client::AttemptStatus, + pub attempt_placement: Option, + pub diff_lines: Vec, + pub text_lines: Vec, + pub prompt: Option, + pub diff_raw: Option, +} + +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) -> 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 { + 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!["".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!["".to_string()]); + } else { + self.sd.set_content(diff_lines); + } + } + DetailView::Prompt => { + if text_lines.is_empty() { + self.sd.set_content(vec!["".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, + result: anyhow::Result>, + }, + // Background diff summary events were planned; removed for now to keep code minimal. + /// Autodetection of a likely environment id finished + EnvironmentAutodetected(anyhow::Result), + /// Background completion of environment list fetch + EnvironmentsLoaded(anyhow::Result>), + DetailsDiffLoaded { + id: TaskId, + title: String, + diff: String, + }, + DetailsMessagesLoaded { + id: TaskId, + title: String, + messages: Vec, + prompt: Option, + turn_id: Option, + sibling_turn_ids: Vec, + attempt_placement: Option, + attempt_status: codex_cloud_tasks_client::AttemptStatus, + }, + DetailsFailed { + id: TaskId, + title: String, + error: String, + }, + AttemptsLoaded { + id: TaskId, + attempts: Vec, + }, + /// Background completion of new task submission + NewTaskSubmitted(Result), + /// Background completion of apply preflight when opening modal or on demand + ApplyPreflightFinished { + id: TaskId, + title: String, + message: String, + level: ApplyResultLevel, + skipped: Vec, + conflicts: Vec, + }, + /// Background completion of apply action (actual patch application) + ApplyFinished { + id: TaskId, + result: std::result::Result, + }, +} + +// 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, 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> { + 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> { + Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented( + "not used in test", + )) + } + + async fn get_task_messages( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_client::Result> { + Ok(vec![]) + } + async fn get_task_text( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_client::Result { + 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> { + Ok(Vec::new()) + } + + async fn apply_task( + &self, + _id: TaskId, + _diff_override: Option, + ) -> codex_cloud_tasks_client::Result { + Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented( + "not used in test", + )) + } + + async fn apply_task_preflight( + &self, + _id: TaskId, + _diff_override: Option, + ) -> codex_cloud_tasks_client::Result { + 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 { + 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"); + } +} diff --git a/codex-rs/cloud-tasks/src/cli.rs b/codex-rs/cloud-tasks/src/cli.rs new file mode 100644 index 0000000000..81125aeb1c --- /dev/null +++ b/codex-rs/cloud-tasks/src/cli.rs @@ -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, +} diff --git a/codex-rs/cloud-tasks/src/env_detect.rs b/codex-rs/cloud-tasks/src/env_detect.rs new file mode 100644 index 0000000000..e7e8fb6b16 --- /dev/null +++ b/codex-rs/cloud-tasks/src/env_detect.rs @@ -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, + #[serde(default)] + is_pinned: Option, + #[serde(default)] + task_count: Option, +} + +#[derive(Debug, Clone)] +pub struct AutodetectSelection { + pub id: String, + pub label: Option, +} + +pub async fn autodetect_environment_id( + base_url: &str, + headers: &HeaderMap, + desired_label: Option, +) -> anyhow::Result { + // 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 = 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::>(&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::(&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 = 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 { + 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( + url: &str, + headers: &HeaderMap, +) -> anyhow::Result { + 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::(&body).map_err(|e| { + anyhow::anyhow!("Decode error for {url}: {e}; content-type={ct}; body={body}") + })?; + Ok(parsed) +} + +fn get_git_origins() -> Vec { + // 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) -> Vec { + 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> { + let mut map: HashMap = 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::>(&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::>(&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 = 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) +} diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs new file mode 100644 index 0000000000..d57a40572c --- /dev/null +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -0,0 +1,1631 @@ +mod app; +mod cli; +pub mod env_detect; +mod new_task; +pub mod scrollable_diff; +mod ui; +pub mod util; +pub use cli::Cli; + +use std::io::IsTerminal; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use tokio::sync::mpsc::UnboundedSender; +use tracing::info; +use tracing_subscriber::EnvFilter; +use util::append_error_log; +use util::set_user_agent_suffix; + +struct ApplyJob { + task_id: codex_cloud_tasks_client::TaskId, + diff_override: Option, +} + +fn level_from_status(status: codex_cloud_tasks_client::ApplyStatus) -> app::ApplyResultLevel { + match status { + codex_cloud_tasks_client::ApplyStatus::Success => app::ApplyResultLevel::Success, + codex_cloud_tasks_client::ApplyStatus::Partial => app::ApplyResultLevel::Partial, + codex_cloud_tasks_client::ApplyStatus::Error => app::ApplyResultLevel::Error, + } +} + +fn spawn_preflight( + app: &mut app::App, + backend: &Arc, + tx: &UnboundedSender, + frame_tx: &UnboundedSender, + title: String, + job: ApplyJob, +) -> bool { + if app.apply_inflight { + app.status = "An apply is already running; wait for it to finish first.".to_string(); + return false; + } + if app.apply_preflight_inflight { + app.status = "A preflight is already running; wait for it to finish first.".to_string(); + return false; + } + + app.apply_preflight_inflight = true; + let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + + let backend = backend.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let ApplyJob { + task_id, + diff_override, + } = job; + let result = codex_cloud_tasks_client::CloudBackend::apply_task_preflight( + &*backend, + task_id.clone(), + diff_override, + ) + .await; + + let event = match result { + Ok(outcome) => { + let level = level_from_status(outcome.status); + app::AppEvent::ApplyPreflightFinished { + id: task_id, + title, + message: outcome.message, + level, + skipped: outcome.skipped_paths, + conflicts: outcome.conflict_paths, + } + } + Err(e) => app::AppEvent::ApplyPreflightFinished { + id: task_id, + title, + message: format!("Preflight failed: {e}"), + level: app::ApplyResultLevel::Error, + skipped: Vec::new(), + conflicts: Vec::new(), + }, + }; + + let _ = tx.send(event); + }); + + true +} + +fn spawn_apply( + app: &mut app::App, + backend: &Arc, + tx: &UnboundedSender, + frame_tx: &UnboundedSender, + job: ApplyJob, +) -> bool { + if app.apply_inflight { + app.status = "An apply is already running; wait for it to finish first.".to_string(); + return false; + } + if app.apply_preflight_inflight { + app.status = "Finish the current preflight before starting another apply.".to_string(); + return false; + } + + app.apply_inflight = true; + let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + + let backend = backend.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let ApplyJob { + task_id, + diff_override, + } = job; + let result = codex_cloud_tasks_client::CloudBackend::apply_task( + &*backend, + task_id.clone(), + diff_override, + ) + .await; + + let event = match result { + Ok(outcome) => app::AppEvent::ApplyFinished { + id: task_id, + result: Ok(outcome), + }, + Err(e) => app::AppEvent::ApplyFinished { + id: task_id, + result: Err(format!("{e}")), + }, + }; + + let _ = tx.send(event); + }); + + true +} + +// logging helper lives in util module + +// (no standalone patch summarizer needed – UI displays raw diffs) + +/// Entry point for the `codex cloud` subcommand. +pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { + // Very minimal logging setup; mirrors other crates' pattern. + let default_level = "error"; + let _ = tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(default_level)) + .unwrap_or_else(|_| EnvFilter::new(default_level)), + ) + .with_ansi(std::io::stderr().is_terminal()) + .with_writer(std::io::stderr) + .try_init(); + + info!("Launching Cloud Tasks list UI"); + set_user_agent_suffix("codex_cloud_tasks_tui"); + + // Default to online unless explicitly configured to use mock. + let use_mock = matches!( + std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), + Some("mock") | Some("MOCK") + ); + + let backend: Arc = if use_mock { + Arc::new(codex_cloud_tasks_client::MockClient) + } else { + // Build an HTTP client against the configured (or default) base URL. + let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + let ua = codex_core::default_client::get_codex_user_agent(); + let mut http = + codex_cloud_tasks_client::HttpClient::new(base_url.clone())?.with_user_agent(ua); + // Log which base URL and path style we're going to use. + let style = if base_url.contains("/backend-api") { + "wham" + } else { + "codex-api" + }; + append_error_log(format!("startup: base_url={base_url} path_style={style}")); + + // Require ChatGPT login (SWIC). Exit with a clear message if missing. + let _token = match codex_core::config::find_codex_home() + .ok() + .map(codex_login::AuthManager::new) + .and_then(|am| am.auth()) + { + Some(auth) => { + // Log account context for debugging workspace selection. + if let Some(acc) = auth.get_account_id() { + append_error_log(format!("auth: mode=ChatGPT account_id={acc}")); + } + match auth.get_token().await { + Ok(t) if !t.is_empty() => { + // Attach token and ChatGPT-Account-Id header if available + http = http.with_bearer_token(t.clone()); + if let Some(acc) = auth + .get_account_id() + .or_else(|| util::extract_chatgpt_account_id(&t)) + { + append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); + http = http.with_chatgpt_account_id(acc); + } + t + } + _ => { + eprintln!( + "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." + ); + std::process::exit(1); + } + } + } + None => { + eprintln!( + "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." + ); + std::process::exit(1); + } + }; + Arc::new(http) + }; + + // Terminal setup + use crossterm::ExecutableCommand; + use crossterm::event::DisableBracketedPaste; + use crossterm::event::EnableBracketedPaste; + use crossterm::event::KeyboardEnhancementFlags; + use crossterm::event::PopKeyboardEnhancementFlags; + use crossterm::event::PushKeyboardEnhancementFlags; + use crossterm::terminal::EnterAlternateScreen; + use crossterm::terminal::LeaveAlternateScreen; + use crossterm::terminal::disable_raw_mode; + use crossterm::terminal::enable_raw_mode; + use ratatui::Terminal; + use ratatui::backend::CrosstermBackend; + let mut stdout = std::io::stdout(); + enable_raw_mode()?; + stdout.execute(EnterAlternateScreen)?; + stdout.execute(EnableBracketedPaste)?; + // Enable enhanced key reporting so Shift+Enter is distinguishable from Enter. + // Some terminals may not support these flags; ignore errors if enabling fails. + let _ = crossterm::execute!( + std::io::stdout(), + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS + ) + ); + let backend_ui = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend_ui)?; + terminal.clear()?; + + // App state + let mut app = app::App::new(); + // Initial load + let force_internal = matches!( + std::env::var("CODEX_CLOUD_TASKS_FORCE_INTERNAL") + .ok() + .as_deref(), + Some("1") | Some("true") | Some("TRUE") + ); + append_error_log(format!( + "startup: wham_force_internal={} ua={}", + force_internal, + codex_core::default_client::get_codex_user_agent() + )); + // Non-blocking initial load so the in-box spinner can animate + app.status = "Loading tasks…".to_string(); + app.refresh_inflight = true; + // New list generation; reset background enrichment coordination + app.list_generation = app.list_generation.saturating_add(1); + app.in_flight.clear(); + // reset any in-flight enrichment state + + // Event stream + use crossterm::event::Event; + use crossterm::event::EventStream; + use crossterm::event::KeyCode; + use crossterm::event::KeyEventKind; + use crossterm::event::KeyModifiers; + use tokio_stream::StreamExt; + let mut events = EventStream::new(); + + // Channel for non-blocking background loads + use tokio::sync::mpsc::unbounded_channel; + let (tx, mut rx) = unbounded_channel::(); + // Kick off the initial load in background + { + let backend = Arc::clone(&backend); + let tx = tx.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend, None).await; + let _ = tx.send(app::AppEvent::TasksLoaded { + env: None, + result: res, + }); + }); + } + // Fetch environment list in parallel so the header can show friendly names quickly. + { + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = util::normalize_base_url( + &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), + ); + let headers = util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); + }); + } + + // Try to auto-detect a likely environment id on startup and refresh if found. + // Do this concurrently so the initial list shows quickly; on success we refetch with filter. + { + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = util::normalize_base_url( + &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), + ); + // Build headers: UA + ChatGPT auth if available + let headers = util::build_chatgpt_headers().await; + + // Run autodetect. If it fails, we keep using "All". + let res = crate::env_detect::autodetect_environment_id(&base_url, &headers, None).await; + let _ = tx.send(app::AppEvent::EnvironmentAutodetected(res)); + }); + } + + // Event-driven redraws with a tiny coalescing scheduler (snappy UI, no fixed 250ms tick). + let mut needs_redraw = true; + use std::time::Instant; + use tokio::time::Instant as TokioInstant; + use tokio::time::sleep_until; + let (frame_tx, mut frame_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (redraw_tx, mut redraw_rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + + // Coalesce frame requests to the earliest deadline; emit a single redraw signal. + tokio::spawn(async move { + let mut next_deadline: Option = None; + loop { + let target = + next_deadline.unwrap_or_else(|| Instant::now() + Duration::from_secs(24 * 60 * 60)); + let sleeper = sleep_until(TokioInstant::from_std(target)); + tokio::pin!(sleeper); + tokio::select! { + recv = frame_rx.recv() => { + match recv { + Some(at) => { + if next_deadline.is_none_or(|cur| at < cur) { + next_deadline = Some(at); + } + continue; // recompute sleep target + } + None => break, + } + } + _ = &mut sleeper => { + if next_deadline.take().is_some() { + let _ = redraw_tx.send(()); + } + } + } + } + }); + // Kick an initial draw so the UI appears immediately. + let _ = frame_tx.send(Instant::now()); + + // Render helper to centralize immediate redraws after handling events. + let render_if_needed = |terminal: &mut Terminal>, + app: &mut app::App, + needs_redraw: &mut bool| + -> anyhow::Result<()> { + if *needs_redraw { + terminal.draw(|f| ui::draw(f, app))?; + *needs_redraw = false; + } + Ok(()) + }; + + let exit_code = loop { + tokio::select! { + // Coalesced redraw requests: spinner animation and paste-burst micro‑flush. + Some(()) = redraw_rx.recv() => { + // Micro‑flush pending first key held by paste‑burst. + if let Some(page) = app.new_task.as_mut() { + if page.composer.flush_paste_burst_if_due() { needs_redraw = true; } + if page.composer.is_in_paste_burst() { + let _ = frame_tx.send(Instant::now() + codex_tui::ComposerInput::recommended_flush_delay()); + } + } + // Advance throbber only while loading. + if app.refresh_inflight + || app.details_inflight + || app.env_loading + || app.apply_preflight_inflight + || app.apply_inflight + { + app.throbber.calc_next(); + needs_redraw = true; + let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + } + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + maybe_app_event = rx.recv() => { + if let Some(ev) = maybe_app_event { + match ev { + app::AppEvent::TasksLoaded { env, result } => { + // Only apply results for the current filter to avoid races. + if env.as_deref() != app.env_filter.as_deref() { + append_error_log(format!( + "refresh.drop: env={} current={}", + env.clone().unwrap_or_else(|| "".to_string()), + app.env_filter.clone().unwrap_or_else(|| "".to_string()) + )); + continue; + } + app.refresh_inflight = false; + match result { + Ok(tasks) => { + append_error_log(format!( + "refresh.apply: env={} count={}", + env.clone().unwrap_or_else(|| "".to_string()), + tasks.len() + )); + app.tasks = tasks; + if app.selected >= app.tasks.len() { app.selected = app.tasks.len().saturating_sub(1); } + app.status = "Loaded tasks".to_string(); + } + Err(e) => { + append_error_log(format!("refresh load_tasks failed: {e}")); + app.status = format!("Failed to load tasks: {e}"); + } + } + needs_redraw = true; + let _ = frame_tx.send(Instant::now()); + } + app::AppEvent::NewTaskSubmitted(result) => { + match result { + Ok(created) => { + append_error_log(format!("new-task: created id={}", created.id.0)); + app.status = format!("Submitted as {}", created.id.0); + app.new_task = None; + // Refresh tasks in background for current filter + app.status = format!("Submitted as {} — refreshing…", created.id.0); + app.refresh_inflight = true; + app.list_generation = app.list_generation.saturating_add(1); + needs_redraw = true; + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend, env_sel.as_deref()).await; + let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + let _ = frame_tx.send(Instant::now()); + } + Err(msg) => { + append_error_log(format!("new-task: submit failed: {msg}")); + if let Some(page) = app.new_task.as_mut() { page.submitting = false; } + app.status = format!("Submit failed: {msg}. See error.log for details."); + needs_redraw = true; + let _ = frame_tx.send(Instant::now()); + } + } + } + // (removed TaskSummaryUpdated; unused in this prototype) + app::AppEvent::ApplyPreflightFinished { id, title, message, level, skipped, conflicts } => { + // Only update if modal is still open and ids match + if let Some(m) = app.apply_modal.as_mut() + && m.task_id == id + { + m.title = title; + m.result_message = Some(message); + m.result_level = Some(level); + m.skipped_paths = skipped; + m.conflict_paths = conflicts; + app.apply_preflight_inflight = false; + needs_redraw = true; + let _ = frame_tx.send(Instant::now()); + } + } + app::AppEvent::EnvironmentsLoaded(result) => { + app.env_loading = false; + match result { + Ok(list) => { + app.environments = list; + app.env_error = None; + app.env_last_loaded = Some(std::time::Instant::now()); + } + Err(e) => { + app.env_error = Some(e.to_string()); + } + } + needs_redraw = true; + let _ = frame_tx.send(Instant::now()); + } + app::AppEvent::EnvironmentAutodetected(result) => { + if let Ok(sel) = result { + // Only apply if user hasn't set a filter yet or it's different. + if app.env_filter.as_deref() != Some(sel.id.as_str()) { + append_error_log(format!( + "env.select: autodetected id={} label={}", + sel.id, + sel.label.clone().unwrap_or_else(|| "".to_string()) + )); + // Preseed environments with detected label so header can show it even before list arrives + if let Some(lbl) = sel.label.clone() { + let present = app.environments.iter().any(|r| r.id == sel.id); + if !present { + app.environments.push(app::EnvironmentRow { id: sel.id.clone(), label: Some(lbl), is_pinned: false, repo_hints: None }); + } + } + app.env_filter = Some(sel.id); + app.status = "Loading tasks…".to_string(); + app.refresh_inflight = true; + app.list_generation = app.list_generation.saturating_add(1); + app.in_flight.clear(); + // reset spinner state + needs_redraw = true; + { + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend, env_sel.as_deref()).await; + let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + } + // Proactively fetch environments to resolve a friendly name for the header. + app.env_loading = true; + { + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = crate::util::normalize_base_url( + &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), + ); + let headers = crate::util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); + }); + } + let _ = frame_tx.send(Instant::now()); + } + } + // on Err, silently continue with All + } + app::AppEvent::DetailsDiffLoaded { id, title, diff } => { + if let Some(ov) = &app.diff_overlay + && ov.task_id != id { + continue; + } + let diff_lines: Vec = diff.lines().map(str::to_string).collect(); + if let Some(ov) = app.diff_overlay.as_mut() { + ov.title = title; + { + let base = ov.base_attempt_mut(); + base.diff_lines = diff_lines.clone(); + base.diff_raw = Some(diff.clone()); + } + ov.base_can_apply = true; + ov.apply_selection_to_fields(); + } else { + let mut overlay = app::DiffOverlay::new(id.clone(), title, None); + { + let base = overlay.base_attempt_mut(); + base.diff_lines = diff_lines.clone(); + base.diff_raw = Some(diff.clone()); + } + overlay.base_can_apply = true; + overlay.current_view = app::DetailView::Diff; + overlay.apply_selection_to_fields(); + app.diff_overlay = Some(overlay); + } + app.details_inflight = false; + app.status.clear(); + needs_redraw = true; + } + app::AppEvent::DetailsMessagesLoaded { + id, + title, + messages, + prompt, + turn_id, + sibling_turn_ids, + attempt_placement, + attempt_status, + } => { + if let Some(ov) = &app.diff_overlay + && ov.task_id != id { + continue; + } + let conv = conversation_lines(prompt.clone(), &messages); + if let Some(ov) = app.diff_overlay.as_mut() { + ov.title = title.clone(); + { + let base = ov.base_attempt_mut(); + base.text_lines = conv.clone(); + base.prompt = prompt.clone(); + base.turn_id = turn_id.clone(); + base.status = attempt_status; + base.attempt_placement = attempt_placement; + } + ov.base_turn_id = turn_id.clone(); + ov.sibling_turn_ids = sibling_turn_ids.clone(); + ov.attempt_total_hint = Some(sibling_turn_ids.len().saturating_add(1)); + if !ov.base_can_apply { + ov.current_view = app::DetailView::Prompt; + } + ov.apply_selection_to_fields(); + if let (Some(turn_id), true) = (turn_id.clone(), !sibling_turn_ids.is_empty()) + && ov.attempts.len() == 1 { + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let task_id = id.clone(); + tokio::spawn(async move { + match codex_cloud_tasks_client::CloudBackend::list_sibling_attempts( + &*backend, + task_id.clone(), + turn_id, + ) + .await + { + Ok(attempts) => { + let _ = tx.send(app::AppEvent::AttemptsLoaded { id: task_id, attempts }); + } + Err(e) => { + crate::util::append_error_log(format!( + "attempts.load failed for {}: {e}", + task_id.0 + )); + } + } + }); + } + } else { + let mut overlay = app::DiffOverlay::new(id.clone(), title, None); + { + let base = overlay.base_attempt_mut(); + base.text_lines = conv.clone(); + base.prompt = prompt.clone(); + base.turn_id = turn_id.clone(); + base.status = attempt_status; + base.attempt_placement = attempt_placement; + } + overlay.base_turn_id = turn_id.clone(); + overlay.sibling_turn_ids = sibling_turn_ids.clone(); + overlay.attempt_total_hint = Some(sibling_turn_ids.len().saturating_add(1)); + overlay.current_view = app::DetailView::Prompt; + overlay.apply_selection_to_fields(); + app.diff_overlay = Some(overlay); + } + app.details_inflight = false; + app.status.clear(); + needs_redraw = true; + } + app::AppEvent::AttemptsLoaded { id, attempts } => { + if let Some(ov) = app.diff_overlay.as_mut() { + if ov.task_id != id { + continue; + } + for attempt in attempts { + if ov + .attempts + .iter() + .any(|existing| existing.turn_id.as_deref() == Some(attempt.turn_id.as_str())) + { + continue; + } + let diff_lines = attempt + .diff + .as_ref() + .map(|d| d.lines().map(str::to_string).collect()) + .unwrap_or_default(); + let text_lines = conversation_lines(None, &attempt.messages); + ov.attempts.push(app::AttemptView { + turn_id: Some(attempt.turn_id.clone()), + status: attempt.status, + attempt_placement: attempt.attempt_placement, + diff_lines, + text_lines, + prompt: None, + diff_raw: attempt.diff.clone(), + }); + } + if ov.attempts.len() > 1 { + let (_, rest) = ov.attempts.split_at_mut(1); + rest.sort_by(|a, b| match (a.attempt_placement, b.attempt_placement) { + (Some(lhs), Some(rhs)) => lhs.cmp(&rhs), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.turn_id.cmp(&b.turn_id), + }); + } + if ov.selected_attempt >= ov.attempts.len() { + ov.selected_attempt = ov.attempts.len().saturating_sub(1); + } + ov.attempt_total_hint = Some(ov.attempts.len()); + ov.apply_selection_to_fields(); + needs_redraw = true; + } + } + app::AppEvent::DetailsFailed { id, title, error } => { + if let Some(ov) = &app.diff_overlay + && ov.task_id != id { + continue; + } + append_error_log(format!("details failed for {}: {error}", id.0)); + let pretty = pretty_lines_from_error(&error); + if let Some(ov) = app.diff_overlay.as_mut() { + ov.title = title.clone(); + { + let base = ov.base_attempt_mut(); + base.diff_lines.clear(); + base.text_lines = pretty.clone(); + base.prompt = None; + } + ov.base_can_apply = false; + ov.current_view = app::DetailView::Prompt; + ov.apply_selection_to_fields(); + } else { + let mut overlay = app::DiffOverlay::new(id.clone(), title, None); + { + let base = overlay.base_attempt_mut(); + base.text_lines = pretty; + } + overlay.base_can_apply = false; + overlay.current_view = app::DetailView::Prompt; + overlay.apply_selection_to_fields(); + app.diff_overlay = Some(overlay); + } + app.details_inflight = false; + needs_redraw = true; + } + app::AppEvent::ApplyFinished { id, result } => { + // Only update if the modal still corresponds to this id. + if let Some(m) = &app.apply_modal { + if m.task_id != id { continue; } + } else { + continue; + } + app.apply_inflight = false; + match result { + Ok(outcome) => { + app.status = outcome.message.clone(); + if matches!(outcome.status, codex_cloud_tasks_client::ApplyStatus::Success) { + app.apply_modal = None; + app.diff_overlay = None; + // Refresh tasks after successful apply + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend, env_sel.as_deref()).await; + let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + } + } + Err(e) => { + append_error_log(format!("apply_task failed for {}: {e}", id.0)); + app.status = format!("Apply failed: {e}"); + } + } + needs_redraw = true; + } + } + } + // Render immediately after processing app events. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + maybe_event = events.next() => { + match maybe_event { + Some(Ok(Event::Paste(pasted))) => { + if app.env_modal.is_some() { + if let Some(m) = app.env_modal.as_mut() { + for ch in pasted.chars() { + match ch { + '\r' | '\n' => continue, + '\t' => m.query.push(' '), + _ => m.query.push(ch), + } + } + } + needs_redraw = true; + } else if let Some(page) = app.new_task.as_mut() + && !page.submitting + { + if page.composer.handle_paste(pasted) { + needs_redraw = true; + } + let _ = frame_tx.send(Instant::now()); + } + } + Some(Ok(Event::Key(key))) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { + // Treat Ctrl-C like pressing 'q' in the current context. + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) + { + if app.env_modal.is_some() { + // Close environment selector if open (don’t quit composer). + app.env_modal = None; + needs_redraw = true; + } else if app.best_of_modal.is_some() { + app.best_of_modal = None; + needs_redraw = true; + } else if app.apply_modal.is_some() { + app.apply_modal = None; + app.status = "Apply canceled".to_string(); + needs_redraw = true; + } else if app.new_task.is_some() { + app.new_task = None; + app.status = "Canceled new task".to_string(); + needs_redraw = true; + } else if app.diff_overlay.is_some() { + app.diff_overlay = None; + needs_redraw = true; + } else { + break 0; + } + // Render updated state immediately before continuing to next loop iteration. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + // Render after New Task branch to reflect input changes immediately. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + continue; + } + let is_ctrl_n = key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('n') | KeyCode::Char('N')) + || matches!(key.code, KeyCode::Char('\u{000E}')); + if is_ctrl_n { + if app.best_of_modal.is_some() { + app.best_of_modal = None; + needs_redraw = true; + } else { + let selected = app.best_of_n.saturating_sub(1).min(3); + app.best_of_modal = Some(app::BestOfModalState { selected }); + app.status = format!( + "Select best-of attempts (current: {} attempt{})", + app.best_of_n, + if app.best_of_n == 1 { "" } else { "s" } + ); + needs_redraw = true; + } + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + continue; + } + if app.best_of_modal.is_some() { + match key.code { + KeyCode::Esc => { + app.best_of_modal = None; + needs_redraw = true; + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(m) = app.best_of_modal.as_mut() { + m.selected = (m.selected + 1).min(3); + } + needs_redraw = true; + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(m) = app.best_of_modal.as_mut() { + m.selected = m.selected.saturating_sub(1); + } + needs_redraw = true; + } + KeyCode::Char('1') | KeyCode::Char('2') | KeyCode::Char('3') | KeyCode::Char('4') => { + if let Some(m) = app.best_of_modal.as_mut() { + let val = match key.code { + KeyCode::Char('1') => 0, + KeyCode::Char('2') => 1, + KeyCode::Char('3') => 2, + KeyCode::Char('4') => 3, + _ => m.selected, + }; + m.selected = val; + } + needs_redraw = true; + } + KeyCode::Enter => { + if let Some(state) = app.best_of_modal.take() { + let new_value = state.selected + 1; + app.best_of_n = new_value; + if let Some(page) = app.new_task.as_mut() { + page.best_of_n = new_value; + } + append_error_log(format!("best-of.select: attempts={new_value}")); + app.status = format!( + "Best-of updated to {new_value} attempt{}", + if new_value == 1 { "" } else { "s" } + ); + needs_redraw = true; + } + } + _ => {} + } + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + continue; + } + // New Task page: Ctrl+O opens environment switcher while composing. + let is_ctrl_o = key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) + || matches!(key.code, KeyCode::Char('\u{000F}')); + if is_ctrl_o && app.new_task.is_some() { + // Close task modal/pending apply if present before opening env modal + app.diff_overlay = None; + app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); + // Cache environments until user explicitly refreshes with 'r' inside the modal. + let should_fetch = app.environments.is_empty(); + if should_fetch { + app.env_loading = true; + app.env_error = None; + // Ensure spinner animates while loading environments. + let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + } + needs_redraw = true; + if should_fetch { + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); + let headers = crate::util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); + }); + } + // Render after opening env modal to show it instantly. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + continue; + } + + // New Task page has priority when active, unless an env modal is open. + if let Some(page) = app.new_task.as_mut() { + if app.env_modal.is_some() { + // Defer handling to env-modal branch below. + } else { + match key.code { + KeyCode::Esc => { + app.new_task = None; + app.status = "Canceled new task".to_string(); + needs_redraw = true; + } + _ => { + if page.submitting { + // Ignore input while submitting + } else if let codex_tui::ComposerAction::Submitted(text) = page.composer.input(key) { + // Submit only if we have an env id + if let Some(env) = page.env_id.clone() { + append_error_log(format!( + "new-task: submit env={} size={}", + env, + text.chars().count() + )); + page.submitting = true; + app.status = "Submitting new task…".to_string(); + let tx = tx.clone(); + let backend = Arc::clone(&backend); + let best_of_n = page.best_of_n; + tokio::spawn(async move { + let result = codex_cloud_tasks_client::CloudBackend::create_task(&*backend, &env, &text, "main", false, best_of_n).await; + let evt = match result { + Ok(ok) => app::AppEvent::NewTaskSubmitted(Ok(ok)), + Err(e) => app::AppEvent::NewTaskSubmitted(Err(format!("{e}"))), + }; + let _ = tx.send(evt); + }); + } else { + app.status = "No environment selected (press 'e' to choose)".to_string(); + } + } + needs_redraw = true; + // If paste‑burst is active, schedule a micro‑flush frame. + if page.composer.is_in_paste_burst() { + let _ = frame_tx.send(Instant::now() + codex_tui::ComposerInput::recommended_flush_delay()); + } + // Always schedule an immediate redraw for key edits in the composer. + let _ = frame_tx.send(Instant::now()); + // Draw now so non-char edits (e.g., Option+Delete) reflect instantly. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + } + continue; + } + } + // If a diff overlay is open, handle its keys first. + if app.apply_modal.is_some() { + // Simple apply confirmation modal: y apply, p preflight, n/Esc cancel + match key.code { + KeyCode::Char('y') => { + if let Some(m) = app.apply_modal.as_ref() { + let title = m.title.clone(); + let job = ApplyJob { + task_id: m.task_id.clone(), + diff_override: m.diff_override.clone(), + }; + if spawn_apply(&mut app, &backend, &tx, &frame_tx, job) { + app.status = format!("Applying '{title}'..."); + } + needs_redraw = true; + } + } + KeyCode::Char('p') => { + if let Some(m) = app.apply_modal.take() { + let title = m.title.clone(); + let job = ApplyJob { + task_id: m.task_id.clone(), + diff_override: m.diff_override.clone(), + }; + if spawn_preflight(&mut app, &backend, &tx, &frame_tx, title.clone(), job) { + app.apply_modal = Some(app::ApplyModalState { + task_id: m.task_id, + title: title.clone(), + result_message: None, + result_level: None, + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + diff_override: m.diff_override, + }); + app.status = format!("Preflighting '{title}'..."); + } else { + app.apply_modal = Some(m); + } + needs_redraw = true; + } + } + KeyCode::Esc + | KeyCode::Char('n') + | KeyCode::Char('q') + | KeyCode::Char('Q') => { app.apply_modal = None; app.status = "Apply canceled".to_string(); needs_redraw = true; } + _ => {} + } + } else if app.diff_overlay.is_some() { + let mut cycle_attempt = |delta: isize| { + if let Some(ov) = app.diff_overlay.as_mut() + && ov.attempt_count() > 1 { + ov.step_attempt(delta); + let total = ov.attempt_display_total(); + let current = ov.selected_attempt + 1; + app.status = format!("Viewing attempt {current} of {total}"); + ov.sd.to_top(); + needs_redraw = true; + } + }; + + match key.code { + KeyCode::Char('a') => { + if app.apply_inflight || app.apply_preflight_inflight { + app.status = "Finish the current apply/preflight before starting another.".to_string(); + needs_redraw = true; + continue; + } + let snapshot = app.diff_overlay.as_ref().map(|ov| { + ( + ov.task_id.clone(), + ov.title.clone(), + ov.current_can_apply(), + ov.current_attempt().and_then(|attempt| attempt.diff_raw.clone()), + ) + }); + if let Some((task_id, title, can_apply, diff_override)) = snapshot { + if can_apply { + let job = ApplyJob { + task_id: task_id.clone(), + diff_override: diff_override.clone(), + }; + if spawn_preflight(&mut app, &backend, &tx, &frame_tx, title.clone(), job) { + app.apply_modal = Some(app::ApplyModalState { + task_id, + title: title.clone(), + result_message: None, + result_level: None, + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + diff_override, + }); + app.status = format!("Preflighting '{title}'..."); + } + } else { + app.status = "No diff available to apply.".to_string(); + } + needs_redraw = true; + } + } + KeyCode::Tab => { + cycle_attempt(1); + } + KeyCode::BackTab => { + cycle_attempt(-1); + } + // From task modal, 'o' should close it and open the env selector + KeyCode::Char('o') | KeyCode::Char('O') => { + app.diff_overlay = None; + app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); + // Use cached environments unless empty + if app.environments.is_empty() { app.env_loading = true; app.env_error = None; } + needs_redraw = true; + if app.environments.is_empty() { + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = crate::util::normalize_base_url( + &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), + ); + let headers = crate::util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); + }); + } + } + KeyCode::Left => { + if let Some(ov) = &mut app.diff_overlay { + let has_text = ov.current_attempt().is_some_and(app::AttemptView::has_text); + let has_diff = ov.current_attempt().is_some_and(app::AttemptView::has_diff) || ov.base_can_apply; + if has_text && has_diff { + ov.set_view(app::DetailView::Prompt); + ov.sd.to_top(); + needs_redraw = true; + } + } + } + KeyCode::Right => { + if let Some(ov) = &mut app.diff_overlay { + let has_text = ov.current_attempt().is_some_and(app::AttemptView::has_text); + let has_diff = ov.current_attempt().is_some_and(app::AttemptView::has_diff) || ov.base_can_apply; + if has_text && has_diff { + ov.set_view(app::DetailView::Diff); + ov.sd.to_top(); + needs_redraw = true; + } + } + } + KeyCode::Char(']') | KeyCode::Char('}') => { + cycle_attempt(1); + } + KeyCode::Char('[') | KeyCode::Char('{') => { + cycle_attempt(-1); + } + KeyCode::Esc | KeyCode::Char('q') => { + app.diff_overlay = None; + needs_redraw = true; + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(1); } + needs_redraw = true; + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(-1); } + needs_redraw = true; + } + KeyCode::PageDown | KeyCode::Char(' ') => { + if let Some(ov) = &mut app.diff_overlay { let step = ov.sd.state.viewport_h.saturating_sub(1) as i16; ov.sd.page_by(step); } + needs_redraw = true; + } + KeyCode::PageUp => { + if let Some(ov) = &mut app.diff_overlay { let step = ov.sd.state.viewport_h.saturating_sub(1) as i16; ov.sd.page_by(-step); } + needs_redraw = true; + } + KeyCode::Home => { if let Some(ov) = &mut app.diff_overlay { ov.sd.to_top(); } needs_redraw = true; } + KeyCode::End => { if let Some(ov) = &mut app.diff_overlay { ov.sd.to_bottom(); } needs_redraw = true; } + _ => {} + } + } else if app.env_modal.is_some() { + // Environment modal key handling + match key.code { + KeyCode::Esc => { app.env_modal = None; needs_redraw = true; } + KeyCode::Char('r') | KeyCode::Char('R') => { + // Trigger refresh of environments + app.env_loading = true; app.env_error = None; needs_redraw = true; + let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); + let headers = crate::util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); + }); + } + KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) => { + if let Some(m) = app.env_modal.as_mut() { m.query.push(ch); } + needs_redraw = true; + } + KeyCode::Backspace => { if let Some(m) = app.env_modal.as_mut() { m.query.pop(); } needs_redraw = true; } + KeyCode::Down | KeyCode::Char('j') => { if let Some(m) = app.env_modal.as_mut() { m.selected = m.selected.saturating_add(1); } needs_redraw = true; } + KeyCode::Up | KeyCode::Char('k') => { if let Some(m) = app.env_modal.as_mut() { m.selected = m.selected.saturating_sub(1); } needs_redraw = true; } + KeyCode::Home => { if let Some(m) = app.env_modal.as_mut() { m.selected = 0; } needs_redraw = true; } + KeyCode::End => { if let Some(m) = app.env_modal.as_mut() { m.selected = app.environments.len(); } needs_redraw = true; } + KeyCode::PageDown | KeyCode::Char(' ') => { if let Some(m) = app.env_modal.as_mut() { let step = 10usize; m.selected = m.selected.saturating_add(step); } needs_redraw = true; } + KeyCode::PageUp => { if let Some(m) = app.env_modal.as_mut() { let step = 10usize; m.selected = m.selected.saturating_sub(step); } needs_redraw = true; } + KeyCode::Char('n') => { + if app.env_filter.is_none() { + app.new_task = Some(crate::new_task::NewTaskPage::new(None, app.best_of_n)); + } else { + app.new_task = Some(crate::new_task::NewTaskPage::new(app.env_filter.clone(), app.best_of_n)); + } + app.status = "New Task: Enter to submit; Esc to cancel".to_string(); + needs_redraw = true; + } + KeyCode::Enter => { + // Resolve selection over filtered set + if let Some(state) = app.env_modal.take() { + let q = state.query.to_lowercase(); + let filtered: Vec<&app::EnvironmentRow> = app.environments.iter().filter(|r| { + if q.is_empty() { return true; } + let mut hay = String::new(); + if let Some(l) = &r.label { hay.push_str(&l.to_lowercase()); hay.push(' '); } + hay.push_str(&r.id.to_lowercase()); + if let Some(h) = &r.repo_hints { hay.push(' '); hay.push_str(&h.to_lowercase()); } + hay.contains(&q) + }).collect(); + // Keep original order (already sorted) — no need to re-sort + let idx = state.selected; + if idx == 0 { app.env_filter = None; append_error_log("env.select: All"); } + else { + let env_idx = idx.saturating_sub(1); + if let Some(row) = filtered.get(env_idx) { + append_error_log(format!( + "env.select: id={} label={}", + row.id, + row.label.clone().unwrap_or_else(|| "".to_string()) + )); + app.env_filter = Some(row.id.clone()); + } + } + // If New Task page is open, reflect the new selection in its header immediately. + if let Some(page) = app.new_task.as_mut() { + page.env_id = app.env_filter.clone(); + } + // Trigger tasks refresh with the selected filter + app.status = "Loading tasks…".to_string(); + app.refresh_inflight = true; + app.list_generation = app.list_generation.saturating_add(1); + app.in_flight.clear(); + // reset spinner state + needs_redraw = true; + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend, env_sel.as_deref()).await; + let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + } + } + _ => {} + } + } else { + // Base list view keys + match key.code { + KeyCode::Char('q') | KeyCode::Esc => { + break 0; + } + KeyCode::Down | KeyCode::Char('j') => { + app.next(); + needs_redraw = true; + } + KeyCode::Up | KeyCode::Char('k') => { + app.prev(); + needs_redraw = true; + } + // Ensure 'r' does not refresh tasks when the env modal is open. + KeyCode::Char('r') | KeyCode::Char('R') => { + if app.env_modal.is_some() { break 0; } + append_error_log(format!( + "refresh.request: env={}", + app.env_filter.clone().unwrap_or_else(|| "".to_string()) + )); + app.status = "Refreshing…".to_string(); + app.refresh_inflight = true; + app.list_generation = app.list_generation.saturating_add(1); + app.in_flight.clear(); + // reset spinner state + needs_redraw = true; + // Spawn background refresh + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend, env_sel.as_deref()).await; + let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + } + KeyCode::Char('o') | KeyCode::Char('O') => { + app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); + // Cache environments until user explicitly refreshes with 'r' inside the modal. + let should_fetch = app.environments.is_empty(); + if should_fetch { app.env_loading = true; app.env_error = None; } + needs_redraw = true; + if should_fetch { + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); + let headers = crate::util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); + }); + } + } + KeyCode::Char('n') => { + let env_opt = app.env_filter.clone(); + app.new_task = Some(crate::new_task::NewTaskPage::new(env_opt, app.best_of_n)); + app.status = "New Task: Enter to submit; Esc to cancel".to_string(); + needs_redraw = true; + } + KeyCode::Enter => { + if let Some(task) = app.tasks.get(app.selected).cloned() { + app.status = format!("Loading details for {title}…", title = task.title); + app.details_inflight = true; + // Open empty overlay immediately; content arrives via events + let overlay = app::DiffOverlay::new( + task.id.clone(), + task.title.clone(), + task.attempt_total, + ); + app.diff_overlay = Some(overlay); + needs_redraw = true; + // Spawn background details load (diff first, then messages fallback) + let id = task.id.clone(); + let title = task.title.clone(); + { + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let diff_id = id.clone(); + let diff_title = title.clone(); + tokio::spawn(async move { + match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, diff_id.clone()).await { + Ok(Some(diff)) => { + let _ = tx.send(app::AppEvent::DetailsDiffLoaded { id: diff_id, title: diff_title, diff }); + } + Ok(None) => { + match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, diff_id.clone()).await { + Ok(text) => { + let evt = app::AppEvent::DetailsMessagesLoaded { + id: diff_id, + title: diff_title, + messages: text.messages, + prompt: text.prompt, + turn_id: text.turn_id, + sibling_turn_ids: text.sibling_turn_ids, + attempt_placement: text.attempt_placement, + attempt_status: text.attempt_status, + }; + let _ = tx.send(evt); + } + Err(e2) => { + let _ = tx.send(app::AppEvent::DetailsFailed { id: diff_id, title: diff_title, error: format!("{e2}") }); + } + } + } + Err(e) => { + append_error_log(format!("get_task_diff failed for {}: {e}", diff_id.0)); + match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, diff_id.clone()).await { + Ok(text) => { + let evt = app::AppEvent::DetailsMessagesLoaded { + id: diff_id, + title: diff_title, + messages: text.messages, + prompt: text.prompt, + turn_id: text.turn_id, + sibling_turn_ids: text.sibling_turn_ids, + attempt_placement: text.attempt_placement, + attempt_status: text.attempt_status, + }; + let _ = tx.send(evt); + } + Err(e2) => { + let _ = tx.send(app::AppEvent::DetailsFailed { id: diff_id, title: diff_title, error: format!("{e2}") }); + } + } + } + } + }); + } + // Also fetch conversation text even when diff exists + { + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let msg_id = id; + let msg_title = title; + tokio::spawn(async move { + if let Ok(text) = codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, msg_id.clone()).await { + let evt = app::AppEvent::DetailsMessagesLoaded { + id: msg_id, + title: msg_title, + messages: text.messages, + prompt: text.prompt, + turn_id: text.turn_id, + sibling_turn_ids: text.sibling_turn_ids, + attempt_placement: text.attempt_placement, + attempt_status: text.attempt_status, + }; + let _ = tx.send(evt); + } + }); + } + // Animate spinner while details load. + let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + } + } + KeyCode::Char('a') => { + if app.apply_inflight || app.apply_preflight_inflight { + app.status = "Finish the current apply/preflight before starting another.".to_string(); + needs_redraw = true; + continue; + } + + if let Some(task) = app.tasks.get(app.selected).cloned() { + match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, task.id.clone()).await { + Ok(Some(diff)) => { + let diff_override = Some(diff.clone()); + let task_id = task.id.clone(); + let title = task.title.clone(); + let job = ApplyJob { + task_id: task_id.clone(), + diff_override: diff_override.clone(), + }; + if spawn_preflight( + &mut app, + &backend, + &tx, + &frame_tx, + title.clone(), + job, + ) { + app.apply_modal = Some(app::ApplyModalState { + task_id, + title: title.clone(), + result_message: None, + result_level: None, + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + diff_override, + }); + app.status = format!("Preflighting '{title}'..."); + } + } + Ok(None) | Err(_) => { + app.status = "No diff available to apply".to_string(); + } + } + needs_redraw = true; + } + } + _ => {} + } + } + // Render after handling a key event (when not quitting). + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + Some(Ok(Event::Resize(_, _))) => { + needs_redraw = true; + // Redraw immediately on resize for snappier UX. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + Some(Err(_)) | None => {} + _ => {} + } + // Fallback: if any other event path requested a redraw, render now. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + } + }; + + // Restore terminal + disable_raw_mode().ok(); + terminal.show_cursor().ok(); + let _ = crossterm::execute!(std::io::stdout(), DisableBracketedPaste); + // Best-effort restore of keyboard enhancement flags before leaving alt screen. + let _ = crossterm::execute!(std::io::stdout(), PopKeyboardEnhancementFlags); + let _ = crossterm::execute!(std::io::stdout(), LeaveAlternateScreen); + + if exit_code != 0 { + std::process::exit(exit_code); + } + Ok(()) +} + +// extract_chatgpt_account_id moved to util.rs + +/// Build plain-text conversation lines: a labeled user prompt followed by assistant messages. +fn conversation_lines(prompt: Option, messages: &[String]) -> Vec { + let mut out: Vec = Vec::new(); + if let Some(p) = prompt { + out.push("user:".to_string()); + for l in p.lines() { + out.push(l.to_string()); + } + out.push(String::new()); + } + if !messages.is_empty() { + out.push("assistant:".to_string()); + for (i, m) in messages.iter().enumerate() { + for l in m.lines() { + out.push(l.to_string()); + } + if i + 1 < messages.len() { + out.push(String::new()); + } + } + } + if out.is_empty() { + out.push("".to_string()); + } + out +} + +/// Convert a verbose HTTP error with embedded JSON body into concise, user-friendly lines +/// for the details overlay. Falls back to a short raw message when parsing fails. +fn pretty_lines_from_error(raw: &str) -> Vec { + let mut lines: Vec = Vec::new(); + let is_no_diff = raw.contains("No output_diff in response."); + let is_no_msgs = raw.contains("No assistant text messages in response."); + if is_no_diff { + lines.push("No diff available for this task.".to_string()); + } else if is_no_msgs { + lines.push("No assistant messages found for this task.".to_string()); + } else { + lines.push("Failed to load task details.".to_string()); + } + + // Try to parse the embedded JSON body: find the first '{' after " body=" and decode. + if let Some(body_idx) = raw.find(" body=") + && let Some(json_start_rel) = raw[body_idx..].find('{') + { + let json_start = body_idx + json_start_rel; + let json_str = raw[json_start..].trim(); + if let Ok(v) = serde_json::from_str::(json_str) { + // Prefer assistant turn context. + let turn = v + .get("current_assistant_turn") + .and_then(|x| x.as_object()) + .cloned() + .or_else(|| { + v.get("current_diff_task_turn") + .and_then(|x| x.as_object()) + .cloned() + }); + if let Some(t) = turn { + if let Some(err) = t.get("error").and_then(|e| e.as_object()) { + let code = err.get("code").and_then(|s| s.as_str()).unwrap_or(""); + let msg = err.get("message").and_then(|s| s.as_str()).unwrap_or(""); + if !code.is_empty() || !msg.is_empty() { + let summary = if code.is_empty() { + msg.to_string() + } else if msg.is_empty() { + code.to_string() + } else { + format!("{code}: {msg}") + }; + lines.push(format!("Assistant error: {summary}")); + } + } + if let Some(status) = t.get("turn_status").and_then(|s| s.as_str()) { + lines.push(format!("Status: {status}")); + } + if let Some(text) = t + .get("latest_event") + .and_then(|e| e.get("text")) + .and_then(|s| s.as_str()) + && !text.trim().is_empty() + { + lines.push(format!("Latest event: {}", text.trim())); + } + } + } + } + + if lines.len() == 1 { + // Parsing yielded nothing; include a trimmed, short raw message tail for context. + let tail = if raw.len() > 320 { + format!("{}…", &raw[..320]) + } else { + raw.to_string() + }; + lines.push(tail); + } else if lines.len() >= 2 { + // Add a hint to refresh when still in progress. + if lines.iter().any(|l| l.contains("in_progress")) { + lines.push("This task may still be running. Press 'r' to refresh.".to_string()); + } + // Avoid an empty overlay + lines.push(String::new()); + } + lines +} + +#[cfg(test)] +mod tests { + use codex_tui::ComposerAction; + use codex_tui::ComposerInput; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + #[test] + fn composer_input_renders_typed_characters() { + let mut composer = ComposerInput::new(); + let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE); + match composer.input(key) { + ComposerAction::Submitted(_) => panic!("unexpected submission"), + ComposerAction::None => {} + } + + let area = Rect::new(0, 0, 20, 5); + let mut buf = Buffer::empty(area); + composer.render_ref(area, &mut buf); + + let found = buf.content().iter().any(|cell| cell.symbol() == "a"); + assert!(found, "typed character was not rendered: {buf:?}"); + + composer.set_hint_items(vec![("⌃O", "env"), ("⌃C", "quit")]); + composer.render_ref(area, &mut buf); + let footer = buf + .content() + .iter() + .skip((area.width as usize) * (area.height as usize - 1)) + .map(ratatui::buffer::Cell::symbol) + .collect::>() + .join(""); + assert!(footer.contains("⌃O env")); + } +} diff --git a/codex-rs/cloud-tasks/src/new_task.rs b/codex-rs/cloud-tasks/src/new_task.rs new file mode 100644 index 0000000000..162fd3bb3a --- /dev/null +++ b/codex-rs/cloud-tasks/src/new_task.rs @@ -0,0 +1,35 @@ +use codex_tui::ComposerInput; + +pub struct NewTaskPage { + pub composer: ComposerInput, + pub submitting: bool, + pub env_id: Option, + pub best_of_n: usize, +} + +impl NewTaskPage { + pub fn new(env_id: Option, 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) + } +} diff --git a/codex-rs/cloud-tasks/src/scrollable_diff.rs b/codex-rs/cloud-tasks/src/scrollable_diff.rs new file mode 100644 index 0000000000..97dfb24895 --- /dev/null +++ b/codex-rs/cloud-tasks/src/scrollable_diff.rs @@ -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, + wrapped: Vec, + wrapped_src_idx: Vec, + wrap_cols: Option, + 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) { + 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 { + 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 = Vec::new(); + let mut out_idx: Vec = 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 = 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; + } +} diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs new file mode 100644 index 0000000000..94fb3ad97d --- /dev/null +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -0,0 +1,1048 @@ +use ratatui::layout::Constraint; +use ratatui::layout::Direction; +use ratatui::layout::Layout; +use ratatui::prelude::*; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Clear; +use ratatui::widgets::List; +use ratatui::widgets::ListItem; +use ratatui::widgets::ListState; +use ratatui::widgets::Padding; +use ratatui::widgets::Paragraph; +use std::sync::OnceLock; + +use crate::app::App; +use crate::app::AttemptView; +use chrono::Local; +use chrono::Utc; +use codex_cloud_tasks_client::AttemptStatus; +use codex_cloud_tasks_client::TaskStatus; +use codex_tui::render_markdown_text; + +pub fn draw(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // list + Constraint::Length(2), // two-line footer (help + status) + ]) + .split(area); + if app.new_task.is_some() { + draw_new_task_page(frame, chunks[0], app); + draw_footer(frame, chunks[1], app); + } else { + draw_list(frame, chunks[0], app); + draw_footer(frame, chunks[1], app); + } + + if app.diff_overlay.is_some() { + draw_diff_overlay(frame, area, app); + } + if app.env_modal.is_some() { + draw_env_modal(frame, area, app); + } + if app.best_of_modal.is_some() { + draw_best_of_modal(frame, area, app); + } + if app.apply_modal.is_some() { + draw_apply_modal(frame, area, app); + } +} + +// ===== Overlay helpers (geometry + styling) ===== +static ROUNDED: OnceLock = OnceLock::new(); + +fn rounded_enabled() -> bool { + *ROUNDED.get_or_init(|| { + std::env::var("CODEX_TUI_ROUNDED") + .ok() + .map(|v| v == "1") + .unwrap_or(true) + }) +} + +fn overlay_outer(area: Rect) -> Rect { + let outer_v = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(10), + Constraint::Percentage(80), + Constraint::Percentage(10), + ]) + .split(area)[1]; + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(10), + Constraint::Percentage(80), + Constraint::Percentage(10), + ]) + .split(outer_v)[1] +} + +fn overlay_block() -> Block<'static> { + let base = Block::default().borders(Borders::ALL); + let base = if rounded_enabled() { + base.border_type(BorderType::Rounded) + } else { + base + }; + base.padding(Padding::new(2, 2, 1, 1)) +} + +fn overlay_content(area: Rect) -> Rect { + overlay_block().inner(area) +} + +pub fn draw_new_task_page(frame: &mut Frame, area: Rect, app: &mut App) { + let title_spans = { + let mut spans: Vec = vec!["New Task".magenta().bold()]; + if let Some(id) = app + .new_task + .as_ref() + .and_then(|p| p.env_id.as_ref()) + .cloned() + { + spans.push(" • ".into()); + // Try to map id to label + let label = app + .environments + .iter() + .find(|r| r.id == id) + .and_then(|r| r.label.clone()) + .unwrap_or(id); + spans.push(label.dim()); + } else { + spans.push(" • ".into()); + spans.push("Env: none (press ctrl-o to choose)".red()); + } + if let Some(page) = app.new_task.as_ref() { + spans.push(" • ".into()); + let attempts = page.best_of_n; + let label = format!( + "{} attempt{}", + attempts, + if attempts == 1 { "" } else { "s" } + ); + spans.push(label.cyan()); + } + spans + }; + let block = Block::default() + .borders(Borders::ALL) + .title(Line::from(title_spans)); + + frame.render_widget(Clear, area); + frame.render_widget(block.clone(), area); + let content = block.inner(area); + + // Expand composer height up to (terminal height - 6), with a 3-line minimum. + let max_allowed = frame.area().height.saturating_sub(6).max(3); + let desired = app + .new_task + .as_ref() + .map(|p| p.composer.desired_height(content.width)) + .unwrap_or(3) + .clamp(3, max_allowed); + + // Anchor the composer to the bottom-left by allocating a flexible spacer + // above it and a fixed `desired`-height area for the composer. + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(desired)]) + .split(content); + let composer_area = rows[1]; + + if let Some(page) = app.new_task.as_ref() { + page.composer.render_ref(composer_area, frame.buffer_mut()); + // Composer renders its own footer hints; no extra row here. + } + + // Place cursor where composer wants it + if let Some(page) = app.new_task.as_ref() + && let Some((x, y)) = page.composer.cursor_pos(composer_area) + { + frame.set_cursor_position((x, y)); + } +} + +fn draw_list(frame: &mut Frame, area: Rect, app: &mut App) { + let items: Vec = app.tasks.iter().map(|t| render_task_item(app, t)).collect(); + + // Selection reflects the actual task index (no artificial spacer item). + let mut state = ListState::default().with_selected(Some(app.selected)); + // Dim task list when a modal/overlay is active to emphasize focus. + let dim_bg = app.env_modal.is_some() + || app.apply_modal.is_some() + || app.best_of_modal.is_some() + || app.diff_overlay.is_some(); + // Dynamic title includes current environment filter + let suffix_span = if let Some(ref id) = app.env_filter { + let label = app + .environments + .iter() + .find(|r| &r.id == id) + .and_then(|r| r.label.clone()) + .unwrap_or_else(|| "Selected".to_string()); + format!(" • {label}").dim() + } else { + " • All".dim() + }; + // Percent scrolled based on selection position in the list (0% at top, 100% at bottom). + let percent_span = if app.tasks.len() <= 1 { + " • 0%".dim() + } else { + let p = ((app.selected as f32) / ((app.tasks.len() - 1) as f32) * 100.0).round() as i32; + format!(" • {}%", p.clamp(0, 100)).dim() + }; + let title_line = { + let base = Line::from(vec!["Cloud Tasks".into(), suffix_span, percent_span]); + if dim_bg { + base.style(Style::default().add_modifier(Modifier::DIM)) + } else { + base + } + }; + let block = Block::default().borders(Borders::ALL).title(title_line); + // Render the outer block first + frame.render_widget(block.clone(), area); + // Draw list inside with a persistent top spacer row + let inner = block.inner(area); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(inner); + let mut list = List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().bold()); + if dim_bg { + list = list.style(Style::default().add_modifier(Modifier::DIM)); + } + frame.render_stateful_widget(list, rows[1], &mut state); + + // In-box spinner during initial/refresh loads + if app.refresh_inflight { + draw_centered_spinner(frame, inner, &mut app.throbber, "Loading tasks…"); + } +} + +fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) { + let mut help = vec![ + "↑/↓".dim(), + ": Move ".dim(), + "r".dim(), + ": Refresh ".dim(), + "Enter".dim(), + ": Open ".dim(), + ]; + // Apply hint; show disabled note when overlay is open without a diff. + if let Some(ov) = app.diff_overlay.as_ref() { + if !ov.current_can_apply() { + help.push("a".dim()); + help.push(": Apply (disabled) ".dim()); + } else { + help.push("a".dim()); + help.push(": Apply ".dim()); + } + if ov.attempt_count() > 1 { + help.push("Tab".dim()); + help.push(": Next attempt ".dim()); + help.push("[ ]".dim()); + help.push(": Cycle attempts ".dim()); + } + } else { + help.push("a".dim()); + help.push(": Apply ".dim()); + } + help.push("o : Set Env ".dim()); + help.push("Ctrl+N".dim()); + help.push(format!(": Attempts {}x ", app.best_of_n).dim()); + if app.new_task.is_some() { + help.push("(editing new task) ".dim()); + } else { + help.push("n : New Task ".dim()); + } + help.extend(vec!["q".dim(), ": Quit ".dim()]); + // Split footer area into two rows: help+spinner (top) and status (bottom) + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)]) + .split(area); + + // Top row: help text + spinner at right + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Fill(1), Constraint::Length(18)]) + .split(rows[0]); + let para = Paragraph::new(Line::from(help)); + // Draw help text; avoid clearing the whole footer area every frame. + frame.render_widget(para, top[0]); + // Right side: spinner or clear the spinner area if idle to prevent stale glyphs. + if app.refresh_inflight + || app.details_inflight + || app.env_loading + || app.apply_preflight_inflight + || app.apply_inflight + { + draw_inline_spinner(frame, top[1], &mut app.throbber, "Loading…"); + } else { + frame.render_widget(Clear, top[1]); + } + + // Bottom row: status/log text across full width (single-line; sanitize newlines) + let mut status_line = app.status.replace('\n', " "); + if status_line.len() > 2000 { + // hard cap to avoid TUI noise + status_line.truncate(2000); + status_line.push('…'); + } + // Clear the status row to avoid trailing characters when the message shrinks. + frame.render_widget(Clear, rows[1]); + let status = Paragraph::new(status_line); + frame.render_widget(status, rows[1]); +} + +fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { + let inner = overlay_outer(area); + if app.diff_overlay.is_none() { + return; + } + let ov_can_apply = app + .diff_overlay + .as_ref() + .map(super::app::DiffOverlay::current_can_apply) + .unwrap_or(false); + let is_error = app + .diff_overlay + .as_ref() + .and_then(|o| o.sd.wrapped_lines().first().cloned()) + .map(|s| s.trim_start().starts_with("Task failed:")) + .unwrap_or(false) + && !ov_can_apply; + let title = app + .diff_overlay + .as_ref() + .map(|o| o.title.clone()) + .unwrap_or_default(); + + // Title block + let title_ref = title.as_str(); + let mut title_spans: Vec = if is_error { + vec![ + "Details ".magenta(), + "[FAILED]".red().bold(), + " ".into(), + title_ref.magenta(), + ] + } else if ov_can_apply { + vec!["Diff: ".magenta(), title_ref.magenta()] + } else { + vec!["Details: ".magenta(), title_ref.magenta()] + }; + if let Some(p) = app + .diff_overlay + .as_ref() + .and_then(|o| o.sd.percent_scrolled()) + { + title_spans.push(" • ".dim()); + title_spans.push(format!("{p}%").dim()); + } + frame.render_widget(Clear, inner); + frame.render_widget( + overlay_block().title(Line::from(title_spans)).clone(), + inner, + ); + + // Content area and optional status bar + let content_full = overlay_content(inner); + let mut content_area = content_full; + if let Some(ov) = app.diff_overlay.as_mut() { + let has_text = ov.current_attempt().is_some_and(AttemptView::has_text); + let has_diff = ov.current_attempt().is_some_and(AttemptView::has_diff) || ov.base_can_apply; + if has_diff || has_text { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(content_full); + // Status bar label + let mut spans: Vec = Vec::new(); + if has_diff && has_text { + let prompt_lbl = if matches!(ov.current_view, crate::app::DetailView::Prompt) { + "[Prompt]".magenta().bold() + } else { + "Prompt".dim() + }; + let diff_lbl = if matches!(ov.current_view, crate::app::DetailView::Diff) { + "[Diff]".magenta().bold() + } else { + "Diff".dim() + }; + spans.extend(vec![ + prompt_lbl, + " ".into(), + diff_lbl, + " ".into(), + "(← → to switch view)".dim(), + ]); + } else if has_text { + spans.push("Conversation".magenta().bold()); + } else { + spans.push("Diff".magenta().bold()); + } + if let Some(total) = ov.expected_attempts().or({ + if ov.attempts.is_empty() { + None + } else { + Some(ov.attempts.len()) + } + }) && total > 1 + { + spans.extend(vec![ + " ".into(), + format!("Attempt {}/{}", ov.selected_attempt + 1, total) + .bold() + .dim(), + " ".into(), + "(Tab/Shift-Tab or [ ] to cycle attempts)".dim(), + ]); + } + frame.render_widget(Paragraph::new(Line::from(spans)), rows[0]); + ov.sd.set_width(rows[1].width); + ov.sd.set_viewport(rows[1].height); + content_area = rows[1]; + } else { + ov.sd.set_width(content_full.width); + ov.sd.set_viewport(content_full.height); + content_area = content_full; + } + } + + // Styled content render + // Choose styling by the active view, not just presence of a diff + let is_diff_view = app + .diff_overlay + .as_ref() + .map(|o| matches!(o.current_view, crate::app::DetailView::Diff)) + .unwrap_or(false); + let styled_lines: Vec> = if is_diff_view { + let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines()); + raw.unwrap_or(&[]) + .iter() + .map(|l| style_diff_line(l)) + .collect() + } else { + app.diff_overlay + .as_ref() + .map(|o| style_conversation_lines(&o.sd, o.current_attempt())) + .unwrap_or_default() + }; + let raw_empty = app + .diff_overlay + .as_ref() + .map(|o| o.sd.wrapped_lines().is_empty()) + .unwrap_or(true); + if app.details_inflight && raw_empty { + draw_centered_spinner(frame, content_area, &mut app.throbber, "Loading details…"); + } else { + let scroll = app + .diff_overlay + .as_ref() + .map(|o| o.sd.state.scroll) + .unwrap_or(0); + let content = Paragraph::new(Text::from(styled_lines)).scroll((scroll, 0)); + frame.render_widget(content, content_area); + } +} + +pub fn draw_apply_modal(frame: &mut Frame, area: Rect, app: &mut App) { + use ratatui::widgets::Wrap; + let inner = overlay_outer(area); + let title = Line::from("Apply Changes?".magenta().bold()); + let block = overlay_block().title(title); + frame.render_widget(Clear, inner); + frame.render_widget(block.clone(), inner); + let content = overlay_content(inner); + + if let Some(m) = &app.apply_modal { + // Header + let header = Paragraph::new(Line::from( + format!("Apply '{}' ?", m.title).magenta().bold(), + )) + .wrap(Wrap { trim: true }); + // Footer instructions + let footer = + Paragraph::new(Line::from("Press Y to apply, P to preflight, N to cancel.").dim()) + .wrap(Wrap { trim: true }); + + // Split into header/body/footer + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(content); + + frame.render_widget(header, rows[0]); + // Body: spinner while preflight/apply runs; otherwise show result message and path lists + if app.apply_preflight_inflight { + draw_centered_spinner(frame, rows[1], &mut app.throbber, "Checking…"); + } else if app.apply_inflight { + draw_centered_spinner(frame, rows[1], &mut app.throbber, "Applying…"); + } else if m.result_message.is_none() { + draw_centered_spinner(frame, rows[1], &mut app.throbber, "Loading…"); + } else if let Some(msg) = &m.result_message { + let mut body_lines: Vec = Vec::new(); + let first = match m.result_level { + Some(crate::app::ApplyResultLevel::Success) => msg.clone().green(), + Some(crate::app::ApplyResultLevel::Partial) => msg.clone().magenta(), + Some(crate::app::ApplyResultLevel::Error) => msg.clone().red(), + None => msg.clone().into(), + }; + body_lines.push(Line::from(first)); + + // On partial or error, show conflicts/skips if present + if !matches!(m.result_level, Some(crate::app::ApplyResultLevel::Success)) { + use ratatui::text::Span; + if !m.conflict_paths.is_empty() { + body_lines.push(Line::from("")); + body_lines.push( + Line::from(format!("Conflicts ({}):", m.conflict_paths.len())) + .red() + .bold(), + ); + for p in &m.conflict_paths { + body_lines + .push(Line::from(vec![" • ".into(), Span::raw(p.clone()).dim()])); + } + } + if !m.skipped_paths.is_empty() { + body_lines.push(Line::from("")); + body_lines.push( + Line::from(format!("Skipped ({}):", m.skipped_paths.len())) + .magenta() + .bold(), + ); + for p in &m.skipped_paths { + body_lines + .push(Line::from(vec![" • ".into(), Span::raw(p.clone()).dim()])); + } + } + } + let body = Paragraph::new(body_lines).wrap(Wrap { trim: true }); + frame.render_widget(body, rows[1]); + } + frame.render_widget(footer, rows[2]); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ConversationSpeaker { + User, + Assistant, +} + +fn style_conversation_lines( + sd: &crate::scrollable_diff::ScrollableDiff, + attempt: Option<&AttemptView>, +) -> Vec> { + use ratatui::text::Span; + + let wrapped = sd.wrapped_lines(); + if wrapped.is_empty() { + return Vec::new(); + } + + let indices = sd.wrapped_src_indices(); + let mut styled: Vec> = Vec::new(); + let mut speaker: Option = None; + let mut in_code = false; + let mut last_src: Option = None; + let mut bullet_indent: Option = None; + + for (display, &src_idx) in wrapped.iter().zip(indices.iter()) { + let raw = sd.raw_line_at(src_idx); + let trimmed = raw.trim(); + let is_new_raw = last_src.map(|prev| prev != src_idx).unwrap_or(true); + + if trimmed.eq_ignore_ascii_case("user:") { + speaker = Some(ConversationSpeaker::User); + in_code = false; + bullet_indent = None; + styled.push(conversation_header_line(ConversationSpeaker::User, None)); + last_src = Some(src_idx); + continue; + } + if trimmed.eq_ignore_ascii_case("assistant:") { + speaker = Some(ConversationSpeaker::Assistant); + in_code = false; + bullet_indent = None; + styled.push(conversation_header_line( + ConversationSpeaker::Assistant, + attempt, + )); + last_src = Some(src_idx); + continue; + } + if raw.is_empty() { + let mut spans: Vec = Vec::new(); + if let Some(role) = speaker { + spans.push(conversation_gutter_span(role)); + } else { + spans.push(Span::raw(String::new())); + } + styled.push(Line::from(spans)); + last_src = Some(src_idx); + bullet_indent = None; + continue; + } + + if is_new_raw { + let trimmed_start = raw.trim_start(); + if trimmed_start.starts_with("```") { + in_code = !in_code; + bullet_indent = None; + } else if !in_code + && (trimmed_start.starts_with("- ") || trimmed_start.starts_with("* ")) + { + let indent = raw.chars().take_while(|c| c.is_whitespace()).count(); + bullet_indent = Some(indent); + } else if !in_code { + bullet_indent = None; + } + } + + let mut spans: Vec = Vec::new(); + if let Some(role) = speaker { + spans.push(conversation_gutter_span(role)); + } + + spans.extend(conversation_text_spans( + display, + in_code, + is_new_raw, + bullet_indent, + )); + + styled.push(Line::from(spans)); + last_src = Some(src_idx); + } + + if styled.is_empty() { + wrapped.iter().map(|l| Line::from(l.to_string())).collect() + } else { + styled + } +} + +fn conversation_header_line( + speaker: ConversationSpeaker, + attempt: Option<&AttemptView>, +) -> Line<'static> { + use ratatui::text::Span; + + let mut spans: Vec = vec!["╭ ".dim()]; + match speaker { + ConversationSpeaker::User => { + spans.push("User".cyan().bold()); + spans.push(" prompt".dim()); + } + ConversationSpeaker::Assistant => { + spans.push("Assistant".magenta().bold()); + spans.push(" response".dim()); + if let Some(attempt) = attempt + && let Some(status_span) = attempt_status_span(attempt.status) + { + spans.push(" • ".dim()); + spans.push(status_span); + } + } + } + Line::from(spans) +} + +fn conversation_gutter_span(speaker: ConversationSpeaker) -> ratatui::text::Span<'static> { + match speaker { + ConversationSpeaker::User => "│ ".cyan().dim(), + ConversationSpeaker::Assistant => "│ ".magenta().dim(), + } +} + +fn conversation_text_spans( + display: &str, + in_code: bool, + is_new_raw: bool, + bullet_indent: Option, +) -> Vec> { + use ratatui::text::Span; + + if in_code { + return vec![Span::styled( + display.to_string(), + Style::default().fg(Color::Cyan), + )]; + } + + let trimmed = display.trim_start(); + + if let Some(indent) = bullet_indent { + if is_new_raw { + let rest = trimmed.get(2..).unwrap_or("").trim_start(); + let mut spans: Vec = Vec::new(); + if indent > 0 { + spans.push(Span::raw(" ".repeat(indent))); + } + spans.push("• ".into()); + spans.push(Span::raw(rest.to_string())); + return spans; + } + let mut continuation = String::new(); + continuation.push_str(&" ".repeat(indent + 2)); + continuation.push_str(trimmed); + return vec![Span::raw(continuation)]; + } + + if is_new_raw + && (trimmed.starts_with("### ") || trimmed.starts_with("## ") || trimmed.starts_with("# ")) + { + return vec![Span::styled( + display.to_string(), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )]; + } + + let mut rendered = render_markdown_text(display); + if rendered.lines.is_empty() { + return vec![Span::raw(display.to_string())]; + } + // `render_markdown_text` can yield multiple lines when the input contains + // explicit breaks. We only expect a single line here; join the spans of the + // first rendered line for styling. + rendered.lines.remove(0).spans.into_iter().collect() +} + +fn attempt_status_span(status: AttemptStatus) -> Option> { + match status { + AttemptStatus::Completed => Some("Completed".green()), + AttemptStatus::Failed => Some("Failed".red().bold()), + AttemptStatus::InProgress => Some("In progress".magenta()), + AttemptStatus::Pending => Some("Pending".cyan()), + AttemptStatus::Cancelled => Some("Cancelled".dim()), + AttemptStatus::Unknown => None, + } +} + +fn style_diff_line(raw: &str) -> Line<'static> { + use ratatui::style::Color; + use ratatui::style::Modifier; + use ratatui::style::Style; + use ratatui::text::Span; + + if raw.starts_with("@@") { + return Line::from(vec![Span::styled( + raw.to_string(), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )]); + } + if raw.starts_with("+++") || raw.starts_with("---") { + return Line::from(vec![Span::styled( + raw.to_string(), + Style::default().add_modifier(Modifier::DIM), + )]); + } + if raw.starts_with('+') { + return Line::from(vec![Span::styled( + raw.to_string(), + Style::default().fg(Color::Green), + )]); + } + if raw.starts_with('-') { + return Line::from(vec![Span::styled( + raw.to_string(), + Style::default().fg(Color::Red), + )]); + } + Line::from(vec![Span::raw(raw.to_string())]) +} + +fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> ListItem<'static> { + let status = match t.status { + TaskStatus::Ready => "READY".green(), + TaskStatus::Pending => "PENDING".magenta(), + TaskStatus::Applied => "APPLIED".blue(), + TaskStatus::Error => "ERROR".red(), + }; + + // Title line: [STATUS] Title + let title = Line::from(vec![ + "[".into(), + status, + "] ".into(), + t.title.clone().into(), + ]); + + // Meta line: environment label and relative time (dim) + let mut meta: Vec = Vec::new(); + if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) { + meta.push(lbl.clone().dim()); + } + let when = format_relative_time(t.updated_at).dim(); + if !meta.is_empty() { + meta.push(" ".into()); + meta.push("•".dim()); + meta.push(" ".into()); + } + meta.push(when); + let meta_line = Line::from(meta); + + // Subline: summary when present; otherwise show "no diff" + let sub = if t.summary.files_changed > 0 + || t.summary.lines_added > 0 + || t.summary.lines_removed > 0 + { + let adds = t.summary.lines_added; + let dels = t.summary.lines_removed; + let files = t.summary.files_changed; + Line::from(vec![ + format!("+{adds}").green(), + "/".into(), + format!("−{dels}").red(), + " ".into(), + "•".dim(), + " ".into(), + format!("{files}").into(), + " ".into(), + "files".dim(), + ]) + } else { + Line::from("no diff".to_string().dim()) + }; + + // Insert a blank spacer line after the summary to separate tasks + let spacer = Line::from(""); + ListItem::new(vec![title, meta_line, sub, spacer]) +} + +fn format_relative_time(ts: chrono::DateTime) -> String { + let now = Utc::now(); + let mut secs = (now - ts).num_seconds(); + if secs < 0 { + secs = 0; + } + if secs < 60 { + return format!("{secs}s ago"); + } + let mins = secs / 60; + if mins < 60 { + return format!("{mins}m ago"); + } + let hours = mins / 60; + if hours < 24 { + return format!("{hours}h ago"); + } + let local = ts.with_timezone(&Local); + local.format("%b %e %H:%M").to_string() +} + +fn draw_inline_spinner( + frame: &mut Frame, + area: Rect, + state: &mut throbber_widgets_tui::ThrobberState, + label: &str, +) { + use ratatui::style::Style; + use throbber_widgets_tui::BRAILLE_EIGHT; + use throbber_widgets_tui::Throbber; + use throbber_widgets_tui::WhichUse; + let w = Throbber::default() + .label(label) + .style(Style::default().cyan()) + .throbber_style(Style::default().magenta().bold()) + .throbber_set(BRAILLE_EIGHT) + .use_type(WhichUse::Spin); + frame.render_stateful_widget(w, area, state); +} + +fn draw_centered_spinner( + frame: &mut Frame, + area: Rect, + state: &mut throbber_widgets_tui::ThrobberState, + label: &str, +) { + // Center a 1xN throbber within the given rect + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(50), + Constraint::Length(1), + Constraint::Percentage(49), + ]) + .split(area); + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(50), + Constraint::Length(18), + Constraint::Percentage(50), + ]) + .split(rows[1]); + draw_inline_spinner(frame, cols[1], state, label); +} + +// Styling helpers for diff rendering live inline where used. + +pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) { + use ratatui::widgets::Wrap; + + // Use shared overlay geometry and padding. + let inner = overlay_outer(area); + + // Title: primary only; move long hints to a subheader inside content. + let title = Line::from(vec!["Select Environment".magenta().bold()]); + let block = overlay_block().title(title); + + frame.render_widget(Clear, inner); + frame.render_widget(block.clone(), inner); + let content = overlay_content(inner); + + if app.env_loading { + draw_centered_spinner(frame, content, &mut app.throbber, "Loading environments…"); + return; + } + + // Layout: subheader + search + results list + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // subheader + Constraint::Length(1), // search + Constraint::Min(1), // list + ]) + .split(content); + + // Subheader with usage hints (dim cyan) + let subheader = Paragraph::new(Line::from( + "Type to search, Enter select, Esc cancel; r refresh" + .cyan() + .dim(), + )) + .wrap(Wrap { trim: true }); + frame.render_widget(subheader, rows[0]); + + let query = app + .env_modal + .as_ref() + .map(|m| m.query.clone()) + .unwrap_or_default(); + let ql = query.to_lowercase(); + let search = Paragraph::new(format!("Search: {query}")).wrap(Wrap { trim: true }); + frame.render_widget(search, rows[1]); + + // Filter environments by query (case-insensitive substring over label/id/hints) + let envs: Vec<&crate::app::EnvironmentRow> = app + .environments + .iter() + .filter(|e| { + if ql.is_empty() { + return true; + } + let mut hay = String::new(); + if let Some(l) = &e.label { + hay.push_str(&l.to_lowercase()); + hay.push(' '); + } + hay.push_str(&e.id.to_lowercase()); + if let Some(h) = &e.repo_hints { + hay.push(' '); + hay.push_str(&h.to_lowercase()); + } + hay.contains(&ql) + }) + .collect(); + + let mut items: Vec = Vec::new(); + items.push(ListItem::new(Line::from("All Environments (Global)"))); + for env in envs.iter() { + let primary = env.label.clone().unwrap_or_else(|| "".to_string()); + let mut spans: Vec = vec![primary.into()]; + if env.is_pinned { + spans.push(" ".into()); + spans.push("PINNED".magenta().bold()); + } + spans.push(" ".into()); + spans.push(env.id.clone().dim()); + if let Some(hint) = &env.repo_hints { + spans.push(" ".into()); + spans.push(hint.clone().dim()); + } + items.push(ListItem::new(Line::from(spans))); + } + + let sel_desired = app.env_modal.as_ref().map(|m| m.selected).unwrap_or(0); + let sel = sel_desired.min(envs.len()); + let mut list_state = ListState::default().with_selected(Some(sel)); + let list = List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().bold()) + .block(Block::default().borders(Borders::NONE)); + frame.render_stateful_widget(list, rows[2], &mut list_state); +} + +pub fn draw_best_of_modal(frame: &mut Frame, area: Rect, app: &mut App) { + use ratatui::widgets::Wrap; + + let inner = overlay_outer(area); + let title = Line::from(vec!["Parallel Attempts".magenta().bold()]); + let block = overlay_block().title(title); + + frame.render_widget(Clear, inner); + frame.render_widget(block.clone(), inner); + let content = overlay_content(inner); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(1)]) + .split(content); + + let hint = Paragraph::new(Line::from( + "Use ↑/↓ to choose, 1-4 jump; Enter confirm, Esc cancel" + .cyan() + .dim(), + )) + .wrap(Wrap { trim: true }); + frame.render_widget(hint, rows[0]); + + let selected = app.best_of_modal.as_ref().map(|m| m.selected).unwrap_or(0); + let options = [1usize, 2, 3, 4]; + let mut items: Vec = Vec::new(); + for &attempts in &options { + let mut spans: Vec = + vec![format!("{attempts} attempt{}", if attempts == 1 { "" } else { "s" }).into()]; + spans.push(" ".into()); + spans.push(format!("{attempts}x parallel").dim()); + if attempts == app.best_of_n { + spans.push(" ".into()); + spans.push("Current".magenta().bold()); + } + items.push(ListItem::new(Line::from(spans))); + } + let sel = selected.min(options.len().saturating_sub(1)); + let mut list_state = ListState::default().with_selected(Some(sel)); + let list = List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().bold()) + .block(Block::default().borders(Borders::NONE)); + frame.render_stateful_widget(list, rows[1], &mut list_state); +} diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs new file mode 100644 index 0000000000..9a610da244 --- /dev/null +++ b/codex-rs/cloud-tasks/src/util.rs @@ -0,0 +1,93 @@ +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) { + 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 { + 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(); + 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); + 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 +} diff --git a/codex-rs/cloud-tasks/tests/env_filter.rs b/codex-rs/cloud-tasks/tests/env_filter.rs new file mode 100644 index 0000000000..8c737c6c28 --- /dev/null +++ b/codex-rs/cloud-tasks/tests/env_filter.rs @@ -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: ")); +} diff --git a/codex-rs/codex-backend-openapi-models/Cargo.toml b/codex-rs/codex-backend-openapi-models/Cargo.toml new file mode 100644 index 0000000000..811ee72d8f --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/Cargo.toml @@ -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" diff --git a/codex-rs/codex-backend-openapi-models/src/lib.rs b/codex-rs/codex-backend-openapi-models/src/lib.rs new file mode 100644 index 0000000000..f9e6d52fa8 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/lib.rs @@ -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; diff --git a/codex-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs b/codex-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs new file mode 100644 index 0000000000..725b3a3737 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs @@ -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, + #[serde(rename = "current_user_turn", skip_serializing_if = "Option::is_none")] + pub current_user_turn: Option>, + #[serde( + rename = "current_assistant_turn", + skip_serializing_if = "Option::is_none" + )] + pub current_assistant_turn: Option>, + #[serde( + rename = "current_diff_task_turn", + skip_serializing_if = "Option::is_none" + )] + pub current_diff_task_turn: Option>, +} + +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, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs b/codex-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs new file mode 100644 index 0000000000..92b56db2ca --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs @@ -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, + #[serde(rename = "codex_updated_sha", skip_serializing_if = "Option::is_none")] + pub codex_updated_sha: Option, +} + +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, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/git_pull_request.rs b/codex-rs/codex-backend-openapi-models/src/models/git_pull_request.rs new file mode 100644 index 0000000000..a7e995f3ef --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/git_pull_request.rs @@ -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, + #[serde(rename = "title", skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(rename = "body", skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(rename = "base", skip_serializing_if = "Option::is_none")] + pub base: Option, + #[serde(rename = "head", skip_serializing_if = "Option::is_none")] + pub head: Option, + #[serde(rename = "base_sha", skip_serializing_if = "Option::is_none")] + pub base_sha: Option, + #[serde(rename = "head_sha", skip_serializing_if = "Option::is_none")] + pub head_sha: Option, + #[serde(rename = "merge_commit_sha", skip_serializing_if = "Option::is_none")] + pub merge_commit_sha: Option, + #[serde(rename = "comments", skip_serializing_if = "Option::is_none")] + pub comments: Option, + #[serde(rename = "diff", skip_serializing_if = "Option::is_none")] + pub diff: Option, + #[serde(rename = "user", skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +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, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs new file mode 100644 index 0000000000..e2cb972f10 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -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; diff --git a/codex-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs b/codex-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs new file mode 100644 index 0000000000..5af75afaab --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs @@ -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, + #[serde(rename = "cursor", skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl PaginatedListTaskListItem { + pub fn new(items: Vec) -> PaginatedListTaskListItem { + PaginatedListTaskListItem { + items, + cursor: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/task_list_item.rs b/codex-rs/codex-backend-openapi-models/src/models/task_list_item.rs new file mode 100644 index 0000000000..5f34738a43 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/task_list_item.rs @@ -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, + #[serde(rename = "updated_at", skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(rename = "created_at", skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde( + rename = "task_status_display", + skip_serializing_if = "Option::is_none" + )] + pub task_status_display: Option>, + #[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>, +} + +impl TaskListItem { + pub fn new( + id: String, + title: String, + has_generated_title: Option, + 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, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/task_response.rs b/codex-rs/codex-backend-openapi-models/src/models/task_response.rs new file mode 100644 index 0000000000..6251b56b7e --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/task_response.rs @@ -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, + #[serde(rename = "title")] + pub title: String, + #[serde( + rename = "has_generated_title", + skip_serializing_if = "Option::is_none" + )] + pub has_generated_title: Option, + #[serde(rename = "current_turn_id", skip_serializing_if = "Option::is_none")] + pub current_turn_id: Option, + #[serde(rename = "has_unread_turn", skip_serializing_if = "Option::is_none")] + pub has_unread_turn: Option, + #[serde( + rename = "denormalized_metadata", + skip_serializing_if = "Option::is_none" + )] + pub denormalized_metadata: Option>, + #[serde(rename = "archived")] + pub archived: bool, + #[serde(rename = "external_pull_requests")] + pub external_pull_requests: Vec, +} + +impl TaskResponse { + pub fn new( + id: String, + title: String, + archived: bool, + external_pull_requests: Vec, + ) -> 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, + } + } +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 9b1c4888a4..ff1103c3d2 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -24,6 +24,7 @@ codex-file-search = { workspace = true } codex-mcp-client = { workspace = true } codex-rmcp-client = { workspace = true } codex-protocol = { workspace = true } +codex-otel = { workspace = true, features = ["otel"] } dirs = { workspace = true } env-flags = { workspace = true } eventsource-stream = { workspace = true } @@ -91,6 +92,7 @@ tempfile = { workspace = true } tokio-test = { workspace = true } walkdir = { workspace = true } wiremock = { workspace = true } +tracing-test = { workspace = true, features = ["no-env-filter"] } [package.metadata.cargo-shear] ignored = ["openssl-sys"] diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index f1653bf9e9..5b6728ad0f 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -46,12 +46,13 @@ pub(crate) async fn apply_patch( &turn_context.sandbox_policy, &turn_context.cwd, ) { - SafetyCheck::AutoApprove { .. } => { - InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec { - action, - user_explicitly_approved_this_action: false, - }) - } + SafetyCheck::AutoApprove { + user_explicitly_approved, + .. + } => InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec { + action, + user_explicitly_approved_this_action: user_explicitly_approved, + }), SafetyCheck::AskUser => { // Compute a readable summary of path changes to include in the // approval request so the user can make an informed decision. diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index eddc786484..f7194a0853 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -1,6 +1,19 @@ use std::time::Duration; +use crate::ModelProviderInfo; +use crate::client_common::Prompt; +use crate::client_common::ResponseEvent; +use crate::client_common::ResponseStream; +use crate::error::CodexErr; +use crate::error::Result; +use crate::model_family::ModelFamily; +use crate::openai_tools::create_tools_json_for_chat_completions_api; +use crate::util::backoff; use bytes::Bytes; +use codex_otel::otel_event_manager::OtelEventManager; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ReasoningItemContent; +use codex_protocol::models::ResponseItem; use eventsource_stream::Eventsource; use futures::Stream; use futures::StreamExt; @@ -15,25 +28,13 @@ use tokio::time::timeout; use tracing::debug; use tracing::trace; -use crate::ModelProviderInfo; -use crate::client_common::Prompt; -use crate::client_common::ResponseEvent; -use crate::client_common::ResponseStream; -use crate::error::CodexErr; -use crate::error::Result; -use crate::model_family::ModelFamily; -use crate::openai_tools::create_tools_json_for_chat_completions_api; -use crate::util::backoff; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ReasoningItemContent; -use codex_protocol::models::ResponseItem; - /// Implementation for the classic Chat Completions API. pub(crate) async fn stream_chat_completions( prompt: &Prompt, model_family: &ModelFamily, client: &reqwest::Client, provider: &ModelProviderInfo, + otel_event_manager: &OtelEventManager, ) -> Result { if prompt.output_schema.is_some() { return Err(CodexErr::UnsupportedOperation( @@ -294,10 +295,13 @@ pub(crate) async fn stream_chat_completions( let req_builder = provider.create_request_builder(client, &None).await?; - let res = req_builder - .header(reqwest::header::ACCEPT, "text/event-stream") - .json(&payload) - .send() + let res = otel_event_manager + .log_request(attempt, || { + req_builder + .header(reqwest::header::ACCEPT, "text/event-stream") + .json(&payload) + .send() + }) .await; match res { @@ -308,6 +312,7 @@ pub(crate) async fn stream_chat_completions( stream, tx_event, provider.stream_idle_timeout(), + otel_event_manager.clone(), )); return Ok(ResponseStream { rx_event }); } @@ -351,6 +356,7 @@ async fn process_chat_sse( stream: S, tx_event: mpsc::Sender>, idle_timeout: Duration, + otel_event_manager: OtelEventManager, ) where S: Stream> + Unpin, { @@ -374,7 +380,10 @@ async fn process_chat_sse( let mut reasoning_text = String::new(); loop { - let sse = match timeout(idle_timeout, stream.next()).await { + let sse = match otel_event_manager + .log_sse_event(|| timeout(idle_timeout, stream.next())) + .await + { Ok(Some(Ok(ev))) => ev, Ok(Some(Err(e))) => { let _ = tx_event diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index e8aca68fe8..685fbf45f2 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -47,6 +47,7 @@ use crate::protocol::RateLimitWindow; use crate::protocol::TokenUsage; use crate::token_data::PlanType; use crate::util::backoff; +use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ResponseItem; @@ -73,6 +74,7 @@ struct Error { pub struct ModelClient { config: Arc, auth_manager: Option>, + otel_event_manager: OtelEventManager, client: reqwest::Client, provider: ModelProviderInfo, conversation_id: ConversationId, @@ -84,6 +86,7 @@ impl ModelClient { pub fn new( config: Arc, auth_manager: Option>, + otel_event_manager: OtelEventManager, provider: ModelProviderInfo, effort: Option, summary: ReasoningSummaryConfig, @@ -94,6 +97,7 @@ impl ModelClient { Self { config, auth_manager, + otel_event_manager, client, provider, conversation_id, @@ -127,6 +131,7 @@ impl ModelClient { &self.config.model_family, &self.client, &self.provider, + &self.otel_event_manager, ) .await?; @@ -163,7 +168,12 @@ 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(); @@ -233,7 +243,7 @@ impl ModelClient { let max_attempts = self.provider.request_max_retries(); for attempt in 0..=max_attempts { match self - .attempt_stream_responses(&payload_json, &auth_manager) + .attempt_stream_responses(attempt, &payload_json, &auth_manager) .await { Ok(stream) => { @@ -258,6 +268,7 @@ impl ModelClient { /// Single attempt to start a streaming Responses API call. async fn attempt_stream_responses( &self, + attempt: u64, payload_json: &Value, auth_manager: &Option>, ) -> std::result::Result { @@ -291,7 +302,11 @@ impl ModelClient { req_builder = req_builder.header("chatgpt-account-id", account_id); } - let res = req_builder.send().await; + let res = self + .otel_event_manager + .log_request(attempt, || req_builder.send()) + .await; + if let Ok(resp) = &res { trace!( "Response status: {}, cf-ray: {}", @@ -322,6 +337,7 @@ impl ModelClient { stream, tx_event, self.provider.stream_idle_timeout(), + self.otel_event_manager.clone(), )); Ok(ResponseStream { rx_event }) @@ -399,6 +415,10 @@ impl ModelClient { self.provider.clone() } + pub fn get_otel_event_manager(&self) -> OtelEventManager { + self.otel_event_manager.clone() + } + /// Returns the currently configured model slug. pub fn get_model(&self) -> String { self.config.model.clone() @@ -605,6 +625,7 @@ async fn process_sse( stream: S, tx_event: mpsc::Sender>, idle_timeout: Duration, + otel_event_manager: OtelEventManager, ) where S: Stream> + Unpin, { @@ -616,7 +637,10 @@ async fn process_sse( let mut response_error: Option = None; loop { - let sse = match timeout(idle_timeout, stream.next()).await { + let sse = match otel_event_manager + .log_sse_event(|| timeout(idle_timeout, stream.next())) + .await + { Ok(Some(Ok(sse))) => sse, Ok(Some(Err(e))) => { debug!("SSE Error: {e:#}"); @@ -630,6 +654,21 @@ async fn process_sse( id: response_id, usage, }) => { + if let Some(token_usage) = &usage { + otel_event_manager.sse_event_completed( + token_usage.input_tokens, + token_usage.output_tokens, + token_usage + .input_tokens_details + .as_ref() + .map(|d| d.cached_tokens), + token_usage + .output_tokens_details + .as_ref() + .map(|d| d.reasoning_tokens), + token_usage.total_tokens, + ); + } let event = ResponseEvent::Completed { response_id, token_usage: usage.map(Into::into), @@ -637,12 +676,13 @@ async fn process_sse( 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, + )); + otel_event_manager.see_event_completed_failed(&error); + + let _ = tx_event.send(Err(error)).await; } } return; @@ -746,7 +786,9 @@ async fn process_sse( response_error = Some(CodexErr::Stream(message, delay)); } Err(e) => { - debug!("failed to parse ErrorResponse: {e}"); + let error = format!("failed to parse ErrorResponse: {e}"); + debug!(error); + response_error = Some(CodexErr::Stream(error, None)) } } } @@ -760,7 +802,9 @@ async fn process_sse( response_completed = Some(r); } Err(e) => { - debug!("failed to parse ResponseCompleted: {e}"); + let error = format!("failed to parse ResponseCompleted: {e}"); + debug!(error); + response_error = Some(CodexErr::Stream(error, None)); continue; } }; @@ -807,6 +851,7 @@ async fn process_sse( async fn stream_from_fixture( path: impl AsRef, provider: ModelProviderInfo, + otel_event_manager: OtelEventManager, ) -> Result { let (tx_event, rx_event) = mpsc::channel::>(1600); let f = std::fs::File::open(path.as_ref())?; @@ -825,6 +870,7 @@ async fn stream_from_fixture( stream, tx_event, provider.stream_idle_timeout(), + otel_event_manager, )); Ok(ResponseStream { rx_event }) } @@ -880,6 +926,7 @@ mod tests { async fn collect_events( chunks: &[&[u8]], provider: ModelProviderInfo, + otel_event_manager: OtelEventManager, ) -> Vec> { let mut builder = IoBuilder::new(); for chunk in chunks { @@ -889,7 +936,12 @@ mod tests { let reader = builder.build(); let stream = ReaderStream::new(reader).map_err(CodexErr::Io); let (tx, mut rx) = mpsc::channel::>(16); - tokio::spawn(process_sse(stream, tx, provider.stream_idle_timeout())); + tokio::spawn(process_sse( + stream, + tx, + provider.stream_idle_timeout(), + otel_event_manager, + )); let mut events = Vec::new(); while let Some(ev) = rx.recv().await { @@ -903,6 +955,7 @@ mod tests { async fn run_sse( events: Vec, provider: ModelProviderInfo, + otel_event_manager: OtelEventManager, ) -> Vec { let mut body = String::new(); for e in events { @@ -919,7 +972,12 @@ mod tests { let (tx, mut rx) = mpsc::channel::>(8); let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io); - tokio::spawn(process_sse(stream, tx, provider.stream_idle_timeout())); + tokio::spawn(process_sse( + stream, + tx, + provider.stream_idle_timeout(), + otel_event_manager, + )); let mut out = Vec::new(); while let Some(ev) = rx.recv().await { @@ -928,6 +986,18 @@ mod tests { out } + fn otel_event_manager() -> OtelEventManager { + OtelEventManager::new( + ConversationId::new(), + "test", + "test", + None, + Some(AuthMode::ChatGPT), + false, + "test".to_string(), + ) + } + // ──────────────────────────── // Tests from `implement-test-for-responses-api-sse-parser` // ──────────────────────────── @@ -979,9 +1049,12 @@ mod tests { requires_openai_auth: false, }; + let otel_event_manager = otel_event_manager(); + let events = collect_events( &[sse1.as_bytes(), sse2.as_bytes(), sse3.as_bytes()], provider, + otel_event_manager, ) .await; @@ -1039,7 +1112,9 @@ mod tests { requires_openai_auth: false, }; - let events = collect_events(&[sse1.as_bytes()], provider).await; + let otel_event_manager = otel_event_manager(); + + let events = collect_events(&[sse1.as_bytes()], provider, otel_event_manager).await; assert_eq!(events.len(), 2); @@ -1073,7 +1148,9 @@ mod tests { requires_openai_auth: false, }; - let events = collect_events(&[sse1.as_bytes()], provider).await; + let otel_event_manager = otel_event_manager(); + + let events = collect_events(&[sse1.as_bytes()], provider, otel_event_manager).await; assert_eq!(events.len(), 1); @@ -1178,7 +1255,9 @@ mod tests { requires_openai_auth: false, }; - let out = run_sse(evs, provider).await; + let otel_event_manager = otel_event_manager(); + + let out = run_sse(evs, provider, otel_event_manager).await; assert_eq!(out.len(), case.expected_len, "case {}", case.name); assert!( (case.expect_first)(&out[0]), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 20b3457562..2fb6bbfc17 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1,5 +1,7 @@ use std::borrow::Cow; use std::collections::HashMap; +use std::fmt::Debug; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; @@ -9,6 +11,7 @@ use crate::client_common::REVIEW_PROMPT; use crate::event_mapping::map_response_item_to_event_messages; use crate::function_tool::FunctionCallError; use crate::review_format::format_review_findings_block; +use crate::terminal; use crate::user_notification::UserNotifier; use async_channel::Receiver; use async_channel::Sender; @@ -121,6 +124,8 @@ use crate::unified_exec::UnifiedExecSessionManager; use crate::user_instructions::UserInstructions; use crate::user_notification::UserNotification; use crate::util::backoff; +use codex_otel::otel_event_manager::OtelEventManager; +use codex_otel::otel_event_manager::ToolDecisionSource; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::custom_prompts::CustomPrompt; @@ -418,11 +423,35 @@ impl Session { } } + let otel_event_manager = OtelEventManager::new( + conversation_id, + config.model.as_str(), + config.model_family.slug.as_str(), + auth_manager.auth().and_then(|a| a.get_account_id()), + auth_manager.auth().map(|a| a.mode), + config.otel.log_user_prompt, + terminal::user_agent(), + ); + + otel_event_manager.conversation_starts( + config.model_provider.name.as_str(), + config.model_reasoning_effort, + config.model_reasoning_summary, + config.model_context_window, + config.model_max_output_tokens, + config.model_auto_compact_token_limit, + config.approval_policy, + config.sandbox_policy.clone(), + config.mcp_servers.keys().map(String::as_str).collect(), + config.active_profile.clone(), + ); + // Now that the conversation id is final (may have been updated by resume), // construct the model client. let client = ModelClient::new( config.clone(), Some(auth_manager.clone()), + otel_event_manager, provider.clone(), model_reasoning_effort, model_reasoning_summary, @@ -1108,9 +1137,15 @@ async fn submission_loop( updated_config.model_context_window = Some(model_info.context_window); } + let otel_event_manager = prev.client.get_otel_event_manager().with_model( + updated_config.model.as_str(), + updated_config.model_family.slug.as_str(), + ); + let client = ModelClient::new( Arc::new(updated_config), auth_manager, + otel_event_manager, provider, effective_effort, effective_summary, @@ -1162,6 +1197,10 @@ async fn submission_loop( } } Op::UserInput { items } => { + turn_context + .client + .get_otel_event_manager() + .user_prompt(&items); // attempt to inject input into current task if let Err(items) = sess.inject_input(items).await { // no current task, spawn a new one @@ -1179,6 +1218,10 @@ async fn submission_loop( summary, final_output_json_schema, } => { + turn_context + .client + .get_otel_event_manager() + .user_prompt(&items); // attempt to inject input into current task if let Err(items) = sess.inject_input(items).await { // Derive a fresh TurnContext for this turn using the provided overrides. @@ -1197,11 +1240,18 @@ async fn submission_loop( per_turn_config.model_context_window = Some(model_info.context_window); } + let otel_event_manager = + turn_context.client.get_otel_event_manager().with_model( + per_turn_config.model.as_str(), + per_turn_config.model_family.slug.as_str(), + ); + // Build a new client with per‑turn reasoning settings. // Reuse the same provider and session id; auth defaults to env/API key. let client = ModelClient::new( Arc::new(per_turn_config), auth_manager, + otel_event_manager, provider, effort, summary, @@ -1458,10 +1508,19 @@ async fn spawn_review_thread( per_turn_config.model_context_window = Some(model_info.context_window); } + let otel_event_manager = parent_turn_context + .client + .get_otel_event_manager() + .with_model( + per_turn_config.model.as_str(), + per_turn_config.model_family.slug.as_str(), + ); + let per_turn_config = Arc::new(per_turn_config); let client = ModelClient::new( per_turn_config.clone(), auth_manager, + otel_event_manager, provider, per_turn_config.model_reasoning_effort, per_turn_config.model_reasoning_summary, @@ -2126,16 +2185,21 @@ async fn handle_response_item( .await; Some(resp) } else { - let result = handle_function_call( - sess, - turn_context, - turn_diff_tracker, - sub_id.to_string(), - name, - arguments, - call_id.clone(), - ) - .await; + let result = turn_context + .client + .get_otel_event_manager() + .log_tool_result(name.as_str(), call_id.as_str(), arguments.as_str(), || { + handle_function_call( + sess, + turn_context, + turn_diff_tracker, + sub_id.to_string(), + name.to_owned(), + arguments.to_owned(), + call_id.clone(), + ) + }) + .await; let output = match result { Ok(content) => FunctionCallOutputPayload { @@ -2156,6 +2220,7 @@ async fn handle_response_item( status: _, action, } => { + let name = "local_shell"; let LocalShellAction::Exec(action) = action; tracing::info!("LocalShellCall: {action:?}"); let params = ShellToolCallParams { @@ -2169,11 +2234,18 @@ async fn handle_response_item( (Some(call_id), _) => call_id, (None, Some(id)) => id, (None, None) => { - error!("LocalShellCall without call_id or id"); + let error_message = "LocalShellCall without call_id or id"; + + turn_context + .client + .get_otel_event_manager() + .log_tool_failed(name, error_message); + + error!(error_message); return Ok(Some(ResponseInputItem::FunctionCallOutput { call_id: "".to_string(), output: FunctionCallOutputPayload { - content: "LocalShellCall without call_id or id".to_string(), + content: error_message.to_string(), success: None, }, })); @@ -2182,15 +2254,26 @@ async fn handle_response_item( let exec_params = to_exec_params(params, turn_context); { - let result = handle_container_exec_with_params( - exec_params, - sess, - turn_context, - turn_diff_tracker, - sub_id.to_string(), - effective_call_id.clone(), - ) - .await; + let result = turn_context + .client + .get_otel_event_manager() + .log_tool_result( + name, + effective_call_id.as_str(), + exec_params.command.join(" ").as_str(), + || { + handle_container_exec_with_params( + name, + exec_params, + sess, + turn_context, + turn_diff_tracker, + sub_id.to_string(), + effective_call_id.clone(), + ) + }, + ) + .await; let output = match result { Ok(content) => FunctionCallOutputPayload { @@ -2215,16 +2298,21 @@ async fn handle_response_item( input, status: _, } => { - let result = handle_custom_tool_call( - sess, - turn_context, - turn_diff_tracker, - sub_id.to_string(), - name, - input, - call_id.clone(), - ) - .await; + let result = turn_context + .client + .get_otel_event_manager() + .log_tool_result(name.as_str(), call_id.as_str(), input.as_str(), || { + handle_custom_tool_call( + sess, + turn_context, + turn_diff_tracker, + sub_id.to_string(), + name.to_owned(), + input.to_owned(), + call_id.clone(), + ) + }) + .await; let output = match result { Ok(content) => content, @@ -2330,6 +2418,7 @@ async fn handle_function_call( "container.exec" | "shell" => { let params = parse_container_exec_arguments(arguments, turn_context, &call_id)?; handle_container_exec_with_params( + name.as_str(), params, sess, turn_context, @@ -2393,6 +2482,7 @@ async fn handle_function_call( justification: None, }; handle_container_exec_with_params( + name.as_str(), exec_params, sess, turn_context, @@ -2465,6 +2555,7 @@ async fn handle_custom_tool_call( }; handle_container_exec_with_params( + name.as_str(), exec_params, sess, turn_context, @@ -2507,6 +2598,7 @@ fn parse_container_exec_arguments( } async fn handle_container_exec_with_params( + tool_name: &str, params: ExecParams, sess: &Session, turn_context: &TurnContext, @@ -2514,6 +2606,8 @@ async fn handle_container_exec_with_params( sub_id: String, call_id: String, ) -> Result { + let otel_event_manager = turn_context.client.get_otel_event_manager(); + if params.with_escalated_permissions.unwrap_or(false) && !matches!(turn_context.approval_policy, AskForApproval::OnRequest) { @@ -2889,13 +2983,17 @@ mod tests { use super::*; use crate::config::ConfigOverrides; use crate::config::ConfigToml; + use crate::protocol::CompactedItem; use crate::protocol::InitialHistory; use crate::protocol::ResumedHistory; use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; + use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::models::ContentItem; + use codex_protocol::models::ResponseItem; + use mcp_types::ContentBlock; use mcp_types::TextContent; use pretty_assertions::assert_eq; @@ -3130,6 +3228,18 @@ mod tests { }) } + fn otel_event_manager(conversation_id: ConversationId, config: &Config) -> OtelEventManager { + OtelEventManager::new( + conversation_id, + config.model.as_str(), + config.model_family.slug.as_str(), + None, + Some(AuthMode::ChatGPT), + false, + "test".to_string(), + ) + } + pub(crate) fn make_session_and_context() -> (Session, TurnContext) { let (tx_event, _rx_event) = async_channel::unbounded(); let codex_home = tempfile::tempdir().expect("create temp dir"); @@ -3141,9 +3251,11 @@ mod tests { .expect("load default test config"); let config = Arc::new(config); let conversation_id = ConversationId::default(); + let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); let client = ModelClient::new( config.clone(), None, + otel_event_manager, config.model_provider.clone(), config.model_reasoning_effort, config.model_reasoning_summary, @@ -3212,9 +3324,11 @@ mod tests { .expect("load default test config"); let config = Arc::new(config); let conversation_id = ConversationId::default(); + let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); let client = ModelClient::new( config.clone(), None, + otel_event_manager, config.model_provider.clone(), config.model_reasoning_effort, config.model_reasoning_summary, @@ -3509,10 +3623,12 @@ mod tests { let mut turn_diff_tracker = TurnDiffTracker::new(); + let tool_name = "shell"; let sub_id = "test-sub".to_string(); let call_id = "test-call".to_string(); let resp = handle_container_exec_with_params( + tool_name, params, &session, &turn_context, @@ -3538,6 +3654,7 @@ mod tests { turn_context.sandbox_policy = SandboxPolicy::DangerFullAccess; let resp2 = handle_container_exec_with_params( + tool_name, params2, &session, &turn_context, diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 292b9f7b51..865bc9251f 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1,8 +1,12 @@ use crate::config_profile::ConfigProfile; +use crate::config_types::DEFAULT_OTEL_ENVIRONMENT; use crate::config_types::History; 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::ReasoningSummaryFormat; use crate::config_types::SandboxWorkspaceWrite; use crate::config_types::ShellEnvironmentPolicy; @@ -199,6 +203,9 @@ pub struct Config { /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. pub disable_paste_burst: bool, + + /// OTEL configuration (exporter type, endpoint, headers, etc.). + pub otel: crate::config_types::OtelConfig, } impl Config { @@ -426,7 +433,7 @@ fn set_project_trusted_inner(doc: &mut DocumentMut, project_path: &Path) -> anyh .get_mut(project_key.as_str()) .and_then(|i| i.as_table_mut()) else { - return Err(anyhow::anyhow!("project table missing for {}", project_key)); + return Err(anyhow::anyhow!("project table missing for {project_key}")); }; proj_tbl.set_implicit(false); proj_tbl["trust_level"] = toml_edit::value("trusted"); @@ -719,6 +726,9 @@ pub struct ConfigToml { /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. pub disable_paste_burst: Option, + + /// OTEL configuration. + pub otel: Option, } impl From for UserSavedConfig { @@ -1068,6 +1078,19 @@ impl Config { .as_ref() .map(|t| t.notifications.clone()) .unwrap_or_default(), + 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) } @@ -1809,6 +1832,7 @@ model_verbosity = "high" active_profile: Some("o3".to_string()), disable_paste_burst: false, tui_notifications: Default::default(), + otel: OtelConfig::default(), }, o3_profile_config ); @@ -1868,6 +1892,7 @@ model_verbosity = "high" active_profile: Some("gpt3".to_string()), disable_paste_burst: false, tui_notifications: Default::default(), + otel: OtelConfig::default(), }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -1942,6 +1967,7 @@ model_verbosity = "high" active_profile: Some("zdr".to_string()), disable_paste_burst: false, tui_notifications: Default::default(), + otel: OtelConfig::default(), }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); @@ -2002,6 +2028,7 @@ model_verbosity = "high" active_profile: Some("gpt5".to_string()), disable_paste_burst: false, tui_notifications: Default::default(), + otel: OtelConfig::default(), }; assert_eq!(expected_gpt5_profile_config, gpt5_profile_config); diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 283ae3a480..4728e76ef4 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -13,6 +13,8 @@ use serde::Deserialize; use serde::Serialize; use serde::de::Error as SerdeError; +pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev"; + #[derive(Serialize, Debug, Clone, PartialEq)] pub struct McpServerConfig { #[serde(flatten)] @@ -219,6 +221,64 @@ pub enum HistoryPersistence { None, } +// ===== OTEL configuration ===== + +#[derive(Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum OtelHttpProtocol { + /// Binary payload + Binary, + /// JSON payload + Json, +} + +/// Which OTEL exporter to use. +#[derive(Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum OtelExporterKind { + None, + OtlpHttp { + endpoint: String, + headers: HashMap, + protocol: OtelHttpProtocol, + }, + OtlpGrpc { + endpoint: String, + headers: HashMap, + }, +} + +/// OTEL settings loaded from config.toml. Fields are optional so we can apply defaults. +#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +pub struct OtelConfigToml { + /// Log user prompt in traces + pub log_user_prompt: Option, + + /// Mark traces with environment (dev, staging, prod, test). Defaults to dev. + pub environment: Option, + + /// Exporter to use. Defaults to `otlp-file`. + pub exporter: Option, +} + +/// Effective OTEL settings after defaults are applied. +#[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(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(untagged)] pub enum Notifications { diff --git a/codex-rs/core/src/custom_prompts.rs b/codex-rs/core/src/custom_prompts.rs index 357abef55b..d92c683902 100644 --- a/codex-rs/core/src/custom_prompts.rs +++ b/codex-rs/core/src/custom_prompts.rs @@ -63,16 +63,88 @@ pub async fn discover_prompts_in_excluding( Ok(s) => s, Err(_) => continue, }; + let (description, argument_hint, body) = parse_frontmatter(&content); out.push(CustomPrompt { name, path, - content, + content: body, + description, + argument_hint, }); } out.sort_by(|a, b| a.name.cmp(&b.name)); out } +/// Parse optional YAML-like frontmatter at the beginning of `content`. +/// Supported keys: +/// - `description`: short description shown in the slash popup +/// - `argument-hint` or `argument_hint`: brief hint string shown after the description +/// Returns (description, argument_hint, body_without_frontmatter). +fn parse_frontmatter(content: &str) -> (Option, Option, String) { + let mut segments = content.split_inclusive('\n'); + let Some(first_segment) = segments.next() else { + return (None, None, String::new()); + }; + let first_line = first_segment.trim_end_matches(['\r', '\n']); + if first_line.trim() != "---" { + return (None, None, content.to_string()); + } + + let mut desc: Option = None; + let mut hint: Option = None; + let mut frontmatter_closed = false; + let mut consumed = first_segment.len(); + + for segment in segments { + let line = segment.trim_end_matches(['\r', '\n']); + let trimmed = line.trim(); + + if trimmed == "---" { + frontmatter_closed = true; + consumed += segment.len(); + break; + } + + if trimmed.is_empty() || trimmed.starts_with('#') { + consumed += segment.len(); + continue; + } + + if let Some((k, v)) = trimmed.split_once(':') { + let key = k.trim().to_ascii_lowercase(); + let mut val = v.trim().to_string(); + if val.len() >= 2 { + let bytes = val.as_bytes(); + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + if (first == b'\"' && last == b'\"') || (first == b'\'' && last == b'\'') { + val = val[1..val.len().saturating_sub(1)].to_string(); + } + } + match key.as_str() { + "description" => desc = Some(val), + "argument-hint" | "argument_hint" => hint = Some(val), + _ => {} + } + } + + consumed += segment.len(); + } + + if !frontmatter_closed { + // Unterminated frontmatter: treat input as-is. + return (None, None, content.to_string()); + } + + let body = if consumed >= content.len() { + String::new() + } else { + content[consumed..].to_string() + }; + (desc, hint, body) +} + #[cfg(test)] mod tests { use super::*; @@ -124,4 +196,31 @@ mod tests { let names: Vec = found.into_iter().map(|e| e.name).collect(); assert_eq!(names, vec!["good"]); } + + #[tokio::test] + async fn parses_frontmatter_and_strips_from_body() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + let file = dir.join("withmeta.md"); + let text = "---\nname: ignored\ndescription: \"Quick review command\"\nargument-hint: \"[file] [priority]\"\n---\nActual body with $1 and $ARGUMENTS"; + fs::write(&file, text).unwrap(); + + let found = discover_prompts_in(dir).await; + assert_eq!(found.len(), 1); + let p = &found[0]; + assert_eq!(p.name, "withmeta"); + assert_eq!(p.description.as_deref(), Some("Quick review command")); + assert_eq!(p.argument_hint.as_deref(), Some("[file] [priority]")); + // Body should not include the frontmatter delimiters. + assert_eq!(p.content, "Actual body with $1 and $ARGUMENTS"); + } + + #[test] + fn parse_frontmatter_preserves_body_newlines() { + let content = "---\r\ndescription: \"Line endings\"\r\nargument_hint: \"[arg]\"\r\n---\r\nFirst line\r\nSecond line\r\n"; + let (desc, hint, body) = parse_frontmatter(content); + assert_eq!(desc.as_deref(), Some("Line endings")); + assert_eq!(hint.as_deref(), Some("[arg]")); + assert_eq!(body, "First line\r\nSecond line\r\n"); + } } diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 5ce08d9d5d..5a6ea8d826 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -2,6 +2,7 @@ use crate::spawn::CODEX_SANDBOX_ENV_VAR; use reqwest::header::HeaderValue; use std::sync::LazyLock; use std::sync::Mutex; +use std::sync::OnceLock; /// Set this to add a suffix to the User-Agent string. /// @@ -26,8 +27,15 @@ pub struct Originator { pub value: String, pub header_value: HeaderValue, } +static ORIGINATOR: OnceLock = OnceLock::new(); -pub static ORIGINATOR: LazyLock = LazyLock::new(|| { +#[derive(Debug)] +pub enum SetOriginatorError { + InvalidHeaderValue, + AlreadyInitialized, +} + +fn init_originator_from_env() -> Originator { let default = "codex_cli_rs"; let value = std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR) .unwrap_or_else(|_| default.to_string()); @@ -45,14 +53,34 @@ pub static ORIGINATOR: LazyLock = LazyLock::new(|| { } } } -}); +} + +fn build_originator(value: String) -> Result { + let header_value = + HeaderValue::from_str(&value).map_err(|_| SetOriginatorError::InvalidHeaderValue)?; + Ok(Originator { + value, + header_value, + }) +} + +pub fn set_default_originator(value: &str) -> Result<(), SetOriginatorError> { + let originator = build_originator(value.to_string())?; + ORIGINATOR + .set(originator) + .map_err(|_| SetOriginatorError::AlreadyInitialized) +} + +pub fn originator() -> &'static Originator { + ORIGINATOR.get_or_init(init_originator_from_env) +} pub fn get_codex_user_agent() -> String { let build_version = env!("CARGO_PKG_VERSION"); let os_info = os_info::get(); let prefix = format!( "{}/{build_version} ({} {}; {}) {}", - ORIGINATOR.value.as_str(), + originator().value.as_str(), os_info.os_type(), os_info.version(), os_info.architecture().unwrap_or("unknown"), @@ -100,7 +128,7 @@ fn sanitize_user_agent(candidate: String, fallback: &str) -> String { tracing::warn!( "Falling back to default Codex originator because base user agent string is invalid" ); - ORIGINATOR.value.clone() + originator().value.clone() } } @@ -109,7 +137,7 @@ pub fn create_client() -> reqwest::Client { use reqwest::header::HeaderMap; let mut headers = HeaderMap::new(); - headers.insert("originator", ORIGINATOR.header_value.clone()); + headers.insert("originator", originator().header_value.clone()); let ua = get_codex_user_agent(); let mut builder = reqwest::Client::builder() diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index edc04ab942..4c7dfdcb3c 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -104,3 +104,5 @@ pub use codex_protocol::models::LocalShellExecAction; pub use codex_protocol::models::LocalShellStatus; pub use codex_protocol::models::ReasoningItemContent; pub use codex_protocol::models::ResponseItem; + +pub mod otel_init; diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index a488791c2b..9be7d9568f 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -196,8 +196,7 @@ impl McpConnectionManager { // Validate server name before spawning if !is_valid_mcp_server_name(&server_name) { let error = anyhow::anyhow!( - "invalid server name '{}': must match pattern ^[a-zA-Z0-9_-]+$", - server_name + "invalid server name '{server_name}': must match pattern ^[a-zA-Z0-9_-]+$" ); errors.insert(server_name, error); continue; diff --git a/codex-rs/core/src/otel_init.rs b/codex-rs/core/src/otel_init.rs new file mode 100644 index 0000000000..e7175edc7f --- /dev/null +++ b/codex-rs/core/src/otel_init.rs @@ -0,0 +1,61 @@ +use crate::config::Config; +use crate::config_types::OtelExporterKind as Kind; +use crate::config_types::OtelHttpProtocol as Protocol; +use crate::default_client::originator; +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, Box> { + 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: originator().value.to_owned(), + 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") +} diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 6f06709396..4ad7e73b89 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -36,13 +36,16 @@ pub struct ConversationsPage { pub struct ConversationItem { /// Absolute path to the rollout file. pub path: PathBuf, - /// First up to 5 JSONL records parsed as JSON (includes meta line). + /// First up to `HEAD_RECORD_LIMIT` JSONL records parsed as JSON (includes meta line). pub head: Vec, + /// Last up to `TAIL_RECORD_LIMIT` JSONL response records parsed as JSON. + pub tail: Vec, } /// Hard cap to bound worst‑case work per request. const MAX_SCAN_FILES: usize = 100; const HEAD_RECORD_LIMIT: usize = 10; +const TAIL_RECORD_LIMIT: usize = 10; /// Pagination cursor identifying a file by timestamp and UUID. #[derive(Debug, Clone, PartialEq, Eq)] @@ -176,13 +179,13 @@ async fn traverse_directories_for_paths( } // Read head and simultaneously detect message events within the same // first N JSONL records to avoid a second file read. - let (head, saw_session_meta, saw_user_event) = - read_head_and_flags(&path, HEAD_RECORD_LIMIT) + let (head, tail, saw_session_meta, saw_user_event) = + read_head_and_tail(&path, HEAD_RECORD_LIMIT, TAIL_RECORD_LIMIT) .await - .unwrap_or((Vec::new(), false, false)); + .unwrap_or((Vec::new(), Vec::new(), false, false)); // Apply filters: must have session meta and at least one user message event if saw_session_meta && saw_user_event { - items.push(ConversationItem { path, head }); + items.push(ConversationItem { path, head, tail }); } } } @@ -286,10 +289,11 @@ fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uui Some((ts, uuid)) } -async fn read_head_and_flags( +async fn read_head_and_tail( path: &Path, - max_records: usize, -) -> io::Result<(Vec, bool, bool)> { + head_limit: usize, + tail_limit: usize, +) -> io::Result<(Vec, Vec, bool, bool)> { use tokio::io::AsyncBufReadExt; let file = tokio::fs::File::open(path).await?; @@ -299,7 +303,7 @@ async fn read_head_and_flags( let mut saw_session_meta = false; let mut saw_user_event = false; - while head.len() < max_records { + while head.len() < head_limit { let line_opt = lines.next_line().await?; let Some(line) = line_opt else { break }; let trimmed = line.trim(); @@ -336,7 +340,84 @@ async fn read_head_and_flags( } } - Ok((head, saw_session_meta, saw_user_event)) + let tail = if tail_limit == 0 { + Vec::new() + } else { + read_tail_records(path, tail_limit).await? + }; + + Ok((head, tail, saw_session_meta, saw_user_event)) +} + +async fn read_tail_records(path: &Path, max_records: usize) -> io::Result> { + use std::io::SeekFrom; + use tokio::io::AsyncReadExt; + use tokio::io::AsyncSeekExt; + + if max_records == 0 { + return Ok(Vec::new()); + } + + const CHUNK_SIZE: usize = 8192; + + let mut file = tokio::fs::File::open(path).await?; + let mut pos = file.seek(SeekFrom::End(0)).await?; + if pos == 0 { + return Ok(Vec::new()); + } + + let mut buffer: Vec = Vec::new(); + + loop { + let slice_start = match (pos > 0, buffer.iter().position(|&b| b == b'\n')) { + (true, Some(idx)) => idx + 1, + _ => 0, + }; + let tail = collect_last_response_values(&buffer[slice_start..], max_records); + if tail.len() >= max_records || pos == 0 { + return Ok(tail); + } + + let read_size = CHUNK_SIZE.min(pos as usize); + if read_size == 0 { + return Ok(tail); + } + pos -= read_size as u64; + file.seek(SeekFrom::Start(pos)).await?; + let mut chunk = vec![0; read_size]; + file.read_exact(&mut chunk).await?; + chunk.extend_from_slice(&buffer); + buffer = chunk; + } +} + +fn collect_last_response_values(buffer: &[u8], max_records: usize) -> Vec { + use std::borrow::Cow; + + if buffer.is_empty() || max_records == 0 { + return Vec::new(); + } + + let text: Cow<'_, str> = String::from_utf8_lossy(buffer); + let mut collected_rev: Vec = Vec::new(); + for line in text.lines().rev() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let parsed: serde_json::Result = serde_json::from_str(trimmed); + let Ok(rollout_line) = parsed else { continue }; + if let RolloutItem::ResponseItem(item) = rollout_line.item + && let Ok(val) = serde_json::to_value(item) + { + collected_rev.push(val); + if collected_rev.len() == max_records { + break; + } + } + } + collected_rev.reverse(); + collected_rev } /// Locate a recorded conversation rollout file by its UUID string using the existing diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 23e645413f..6d99dd1357 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -24,7 +24,7 @@ use super::list::Cursor; use super::list::get_conversations; use super::policy::is_persisted_response_item; use crate::config::Config; -use crate::default_client::ORIGINATOR; +use crate::default_client::originator; use crate::git_info::collect_git_info; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::ResumedHistory; @@ -124,7 +124,7 @@ impl RolloutRecorder { id: session_id, timestamp, cwd: config.cwd.clone(), - originator: ORIGINATOR.value.clone(), + originator: originator().value.clone(), cli_version: env!("CARGO_PKG_VERSION").to_string(), instructions, }), diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index 86f47645f9..af5234d31b 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -17,6 +17,18 @@ use crate::rollout::list::ConversationsPage; use crate::rollout::list::Cursor; use crate::rollout::list::get_conversation; use crate::rollout::list::get_conversations; +use anyhow::Result; +use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::CompactedItem; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InputMessageKind; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::UserMessageEvent; fn write_session_file( root: &Path, @@ -146,14 +158,17 @@ async fn test_list_conversations_latest_first() { ConversationItem { path: p1, head: head_3, + tail: Vec::new(), }, ConversationItem { path: p2, head: head_2, + tail: Vec::new(), }, ConversationItem { path: p3, head: head_1, + tail: Vec::new(), }, ], next_cursor: Some(expected_cursor), @@ -219,10 +234,12 @@ async fn test_pagination_cursor() { ConversationItem { path: p5, head: head_5, + tail: Vec::new(), }, ConversationItem { path: p4, head: head_4, + tail: Vec::new(), }, ], next_cursor: Some(expected_cursor1.clone()), @@ -269,10 +286,12 @@ async fn test_pagination_cursor() { ConversationItem { path: p3, head: head_3, + tail: Vec::new(), }, ConversationItem { path: p2, head: head_2, + tail: Vec::new(), }, ], next_cursor: Some(expected_cursor2.clone()), @@ -304,6 +323,7 @@ async fn test_pagination_cursor() { items: vec![ConversationItem { path: p1, head: head_1, + tail: Vec::new(), }], next_cursor: Some(expected_cursor3), num_scanned_files: 5, // scanned 05, 04 (anchor), 03, 02 (anchor), 01 @@ -346,6 +366,7 @@ async fn test_get_conversation_contents() { items: vec![ConversationItem { path: expected_path, head: expected_head, + tail: Vec::new(), }], next_cursor: Some(expected_cursor), num_scanned_files: 1, @@ -366,6 +387,250 @@ async fn test_get_conversation_contents() { assert_eq!(content, expected_content); } +#[tokio::test] +async fn test_tail_includes_last_response_items() -> Result<()> { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-06-01T08-00-00"; + let uuid = Uuid::from_u128(42); + let day_dir = home.join("sessions").join("2025").join("06").join("01"); + fs::create_dir_all(&day_dir)?; + let file_path = day_dir.join(format!("rollout-{ts}-{uuid}.jsonl")); + let mut file = File::create(&file_path)?; + + let conversation_id = ConversationId::from_string(&uuid.to_string())?; + let meta_line = RolloutLine { + timestamp: ts.to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: conversation_id, + timestamp: ts.to_string(), + instructions: None, + cwd: ".".into(), + originator: "test_originator".into(), + cli_version: "test_version".into(), + }, + git: None, + }), + }; + writeln!(file, "{}", serde_json::to_string(&meta_line)?)?; + + let user_event_line = RolloutLine { + timestamp: ts.to_string(), + item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".into(), + kind: Some(InputMessageKind::Plain), + images: None, + })), + }; + writeln!(file, "{}", serde_json::to_string(&user_event_line)?)?; + + let total_messages = 12usize; + for idx in 0..total_messages { + let response_line = RolloutLine { + timestamp: format!("{ts}-{idx:02}"), + item: RolloutItem::ResponseItem(ResponseItem::Message { + id: None, + role: "assistant".into(), + content: vec![ContentItem::OutputText { + text: format!("reply-{idx}"), + }], + }), + }; + writeln!(file, "{}", serde_json::to_string(&response_line)?)?; + } + drop(file); + + let page = get_conversations(home, 1, None).await?; + let item = page.items.first().expect("conversation item"); + let tail_len = item.tail.len(); + assert_eq!(tail_len, 10usize.min(total_messages)); + + let expected: Vec = (total_messages - tail_len..total_messages) + .map(|idx| { + serde_json::to_value(ResponseItem::Message { + id: None, + role: "assistant".into(), + content: vec![ContentItem::OutputText { + text: format!("reply-{idx}"), + }], + }) + .expect("serialize response item") + }) + .collect(); + + assert_eq!(item.tail, expected); + + Ok(()) +} + +#[tokio::test] +async fn test_tail_handles_short_sessions() -> Result<()> { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-06-02T08-30-00"; + let uuid = Uuid::from_u128(7); + let day_dir = home.join("sessions").join("2025").join("06").join("02"); + fs::create_dir_all(&day_dir)?; + let file_path = day_dir.join(format!("rollout-{ts}-{uuid}.jsonl")); + let mut file = File::create(&file_path)?; + + let conversation_id = ConversationId::from_string(&uuid.to_string())?; + let meta_line = RolloutLine { + timestamp: ts.to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: conversation_id, + timestamp: ts.to_string(), + instructions: None, + cwd: ".".into(), + originator: "test_originator".into(), + cli_version: "test_version".into(), + }, + git: None, + }), + }; + writeln!(file, "{}", serde_json::to_string(&meta_line)?)?; + + let user_event_line = RolloutLine { + timestamp: ts.to_string(), + item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hi".into(), + kind: Some(InputMessageKind::Plain), + images: None, + })), + }; + writeln!(file, "{}", serde_json::to_string(&user_event_line)?)?; + + for idx in 0..3 { + let response_line = RolloutLine { + timestamp: format!("{ts}-{idx:02}"), + item: RolloutItem::ResponseItem(ResponseItem::Message { + id: None, + role: "assistant".into(), + content: vec![ContentItem::OutputText { + text: format!("short-{idx}"), + }], + }), + }; + writeln!(file, "{}", serde_json::to_string(&response_line)?)?; + } + drop(file); + + let page = get_conversations(home, 1, None).await?; + let tail = &page.items.first().expect("conversation item").tail; + + assert_eq!(tail.len(), 3); + + let expected: Vec = (0..3) + .map(|idx| { + serde_json::to_value(ResponseItem::Message { + id: None, + role: "assistant".into(), + content: vec![ContentItem::OutputText { + text: format!("short-{idx}"), + }], + }) + .expect("serialize response item") + }) + .collect(); + + assert_eq!(tail, &expected); + + Ok(()) +} + +#[tokio::test] +async fn test_tail_skips_trailing_non_responses() -> Result<()> { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-06-03T10-00-00"; + let uuid = Uuid::from_u128(11); + let day_dir = home.join("sessions").join("2025").join("06").join("03"); + fs::create_dir_all(&day_dir)?; + let file_path = day_dir.join(format!("rollout-{ts}-{uuid}.jsonl")); + let mut file = File::create(&file_path)?; + + let conversation_id = ConversationId::from_string(&uuid.to_string())?; + let meta_line = RolloutLine { + timestamp: ts.to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: conversation_id, + timestamp: ts.to_string(), + instructions: None, + cwd: ".".into(), + originator: "test_originator".into(), + cli_version: "test_version".into(), + }, + git: None, + }), + }; + writeln!(file, "{}", serde_json::to_string(&meta_line)?)?; + + let user_event_line = RolloutLine { + timestamp: ts.to_string(), + item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".into(), + kind: Some(InputMessageKind::Plain), + images: None, + })), + }; + writeln!(file, "{}", serde_json::to_string(&user_event_line)?)?; + + for idx in 0..4 { + let response_line = RolloutLine { + timestamp: format!("{ts}-{idx:02}"), + item: RolloutItem::ResponseItem(ResponseItem::Message { + id: None, + role: "assistant".into(), + content: vec![ContentItem::OutputText { + text: format!("response-{idx}"), + }], + }), + }; + writeln!(file, "{}", serde_json::to_string(&response_line)?)?; + } + + let compacted_line = RolloutLine { + timestamp: format!("{ts}-compacted"), + item: RolloutItem::Compacted(CompactedItem { + message: "compacted".into(), + }), + }; + writeln!(file, "{}", serde_json::to_string(&compacted_line)?)?; + + let shutdown_event = RolloutLine { + timestamp: format!("{ts}-shutdown"), + item: RolloutItem::EventMsg(EventMsg::ShutdownComplete), + }; + writeln!(file, "{}", serde_json::to_string(&shutdown_event)?)?; + drop(file); + + let page = get_conversations(home, 1, None).await?; + let tail = &page.items.first().expect("conversation item").tail; + + let expected: Vec = (0..4) + .map(|idx| { + serde_json::to_value(ResponseItem::Message { + id: None, + role: "assistant".into(), + content: vec![ContentItem::OutputText { + text: format!("response-{idx}"), + }], + }) + .expect("serialize response item") + }) + .collect(); + + assert_eq!(tail, &expected); + + Ok(()) +} + #[tokio::test] async fn test_stable_ordering_same_second_pagination() { let temp = TempDir::new().unwrap(); @@ -410,10 +675,12 @@ async fn test_stable_ordering_same_second_pagination() { ConversationItem { path: p3, head: head(u3), + tail: Vec::new(), }, ConversationItem { path: p2, head: head(u2), + tail: Vec::new(), }, ], next_cursor: Some(expected_cursor1.clone()), @@ -436,6 +703,7 @@ async fn test_stable_ordering_same_second_pagination() { items: vec![ConversationItem { path: p1, head: head(u1), + tail: Vec::new(), }], next_cursor: Some(expected_cursor2), num_scanned_files: 3, // scanned u3, u2 (anchor), u1 diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 0c8dfd9a98..b976ae4a4c 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -15,9 +15,14 @@ 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 }, + Reject { + reason: String, + }, } pub fn assess_patch_safety( @@ -54,12 +59,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, @@ -118,6 +127,7 @@ pub fn assess_command_safety( if is_known_safe_command(command) || approved.contains(command) { return SafetyCheck::AutoApprove { sandbox_type: SandboxType::None, + user_explicitly_approved: false, }; } @@ -143,13 +153,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, @@ -161,7 +175,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 @@ -362,7 +379,8 @@ mod tests { assert_eq!( safety_check, SafetyCheck::AutoApprove { - sandbox_type: SandboxType::None + sandbox_type: SandboxType::None, + user_explicitly_approved: false, } ); } @@ -409,7 +427,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); diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index ba3fe9de79..4551c5ca59 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -11,6 +11,8 @@ use codex_core::ReasoningItemContent; use codex_core::ResponseItem; use codex_core::WireApi; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_otel::otel_event_manager::OtelEventManager; +use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::ConversationId; use core_test_support::load_default_config_for_test; use futures::StreamExt; @@ -70,13 +72,26 @@ async fn run_request(input: Vec) -> Value { let summary = config.model_reasoning_summary; let config = Arc::new(config); + let conversation_id = ConversationId::new(); + + let otel_event_manager = OtelEventManager::new( + conversation_id, + config.model.as_str(), + config.model_family.slug.as_str(), + None, + Some(AuthMode::ChatGPT), + false, + "test".to_string(), + ); + let client = ModelClient::new( Arc::clone(&config), None, + otel_event_manager, provider, effort, summary, - ConversationId::new(), + conversation_id, ); let mut prompt = Prompt::default(); diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 6155d15e62..9338a1af15 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use tracing_test::traced_test; use codex_core::ContentItem; use codex_core::ModelClient; @@ -8,6 +9,8 @@ use codex_core::ResponseEvent; use codex_core::ResponseItem; use codex_core::WireApi; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_otel::otel_event_manager::OtelEventManager; +use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::ConversationId; use core_test_support::load_default_config_for_test; use futures::StreamExt; @@ -23,11 +26,15 @@ fn network_disabled() -> bool { } async fn run_stream(sse_body: &str) -> Vec { + run_stream_with_bytes(sse_body.as_bytes()).await +} + +async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { let server = MockServer::start().await; let template = ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") - .set_body_raw(sse_body.to_string(), "text/event-stream"); + .set_body_bytes(sse_body.to_vec()); Mock::given(method("POST")) .and(path("/v1/chat/completions")) @@ -63,13 +70,26 @@ async fn run_stream(sse_body: &str) -> Vec { let summary = config.model_reasoning_summary; let config = Arc::new(config); + let conversation_id = ConversationId::new(); + + let otel_event_manager = OtelEventManager::new( + conversation_id, + config.model.as_str(), + config.model_family.slug.as_str(), + None, + Some(AuthMode::ChatGPT), + false, + "test".to_string(), + ); + let client = ModelClient::new( Arc::clone(&config), None, + otel_event_manager, provider, effort, summary, - ConversationId::new(), + conversation_id, ); let mut prompt = Prompt::default(); @@ -89,7 +109,8 @@ async fn run_stream(sse_body: &str) -> Vec { while let Some(event) = stream.next().await { match event { Ok(ev) => events.push(ev), - Err(e) => panic!("stream event error: {e}"), + // We still collect the error to exercise telemetry and complete the task. + Err(_e) => break, } } events @@ -318,3 +339,88 @@ async fn streams_reasoning_before_tool_call() { assert!(matches!(events[3], ResponseEvent::Completed { .. })); } + +#[tokio::test] +#[traced_test] +async fn chat_sse_emits_failed_on_parse_error() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let sse_body = concat!("data: not-json\n\n", "data: [DONE]\n\n"); + + let _ = run_stream(sse_body).await; + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| { + line.contains("codex.api_request") && line.contains("http.response.status_code=200") + }) + .map(|_| Ok(())) + .unwrap_or(Err("cannot find codex.api_request event".to_string())) + }); + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| { + line.contains("codex.sse_event") + && line.contains("error.message") + && line.contains("expected ident at line 1 column 2") + }) + .map(|_| Ok(())) + .unwrap_or(Err("cannot find SSE event".to_string())) + }); +} + +#[tokio::test] +#[traced_test] +async fn chat_sse_done_chunk_emits_event() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let sse_body = "data: [DONE]\n\n"; + + let _ = run_stream(sse_body).await; + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| line.contains("codex.sse_event") && line.contains("event.kind=message")) + .map(|_| Ok(())) + .unwrap_or(Err("cannot find SSE event".to_string())) + }); +} + +#[tokio::test] +#[traced_test] +async fn chat_sse_emits_error_on_invalid_utf8() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let _ = run_stream_with_bytes(b"data: \x80\x80\n\n").await; + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| { + line.contains("codex.sse_event") + && line.contains("error.message") + && line.contains("UTF8 error: invalid utf-8 sequence of 1 bytes from index 0") + }) + .map(|_| Ok(())) + .unwrap_or(Err("cannot find SSE event".to_string())) + }); +} diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 6ba10a7042..b13e7599ef 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -75,6 +75,33 @@ pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value { }) } +pub fn ev_custom_tool_call(call_id: &str, name: &str, input: &str) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "custom_tool_call", + "call_id": call_id, + "name": name, + "input": input + } + }) +} + +pub fn ev_local_shell_call(call_id: &str, status: &str, command: Vec<&str>) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "local_shell_call", + "call_id": call_id, + "status": status, + "action": { + "type": "exec", + "command": command, + } + } + }) +} + /// Convenience: SSE event for an `apply_patch` custom tool call with raw patch /// text. This mirrors the payload produced by the Responses API when the model /// invokes `apply_patch` directly (before we convert it to a function call). @@ -114,7 +141,7 @@ pub fn sse_response(body: String) -> ResponseTemplate { .set_body_raw(body, "text/event-stream") } -pub async fn mount_sse_once(server: &MockServer, matcher: M, body: String) +pub async fn mount_sse_once_match(server: &MockServer, matcher: M, body: String) where M: wiremock::Match + Send + Sync + 'static, { @@ -127,6 +154,23 @@ where .await; } +pub async fn mount_sse_once(server: &MockServer, body: String) { + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(sse_response(body)) + .expect(1) + .mount(server) + .await; +} + +pub async fn mount_sse(server: &MockServer, body: String) { + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(sse_response(body)) + .mount(server) + .await; +} + pub async fn start_mock_server() -> MockServer { MockServer::builder() .body_print_limit(BodyPrintLimit::Limited(80_000)) diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index 2fa3d4df6b..368f7f74a8 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -4,7 +4,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use core_test_support::responses::ev_function_call; -use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; @@ -30,7 +30,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { let body = sse(vec![ev_function_call("call_sleep", "shell", &args)]); let server = start_mock_server().await; - mount_sse_once(&server, body_string_contains("start sleep"), body).await; + mount_sse_once_match(&server, body_string_contains("start sleep"), body).await; let codex = test_codex().build(&server).await.unwrap().codex; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cf15e02d29..7b8eb42916 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -16,6 +16,8 @@ use codex_core::built_in_model_providers; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use codex_otel::otel_event_manager::OtelEventManager; +use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::ConversationId; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::WebSearchAction; @@ -664,13 +666,26 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let summary = config.model_reasoning_summary; let config = Arc::new(config); + let conversation_id = ConversationId::new(); + + let otel_event_manager = OtelEventManager::new( + conversation_id, + config.model.as_str(), + config.model_family.slug.as_str(), + None, + Some(AuthMode::ChatGPT), + false, + "test".to_string(), + ); + let client = ModelClient::new( Arc::clone(&config), None, + otel_event_manager, provider, effort, summary, - ConversationId::new(), + conversation_id, ); let mut prompt = Prompt::default(); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index ede25588c0..a8515296e8 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -25,7 +25,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; -use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; @@ -79,19 +79,19 @@ async fn summarize_context_three_requests_and_instructions() { body.contains("\"text\":\"hello world\"") && !body.contains("You have exceeded the maximum number of tokens") }; - mount_sse_once(&server, first_matcher, sse1).await; + mount_sse_once_match(&server, first_matcher, sse1).await; let second_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains("You have exceeded the maximum number of tokens") }; - mount_sse_once(&server, second_matcher, sse2).await; + mount_sse_once_match(&server, second_matcher, sse2).await; let third_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(&format!("\"text\":\"{THIRD_USER_MSG}\"")) }; - mount_sse_once(&server, third_matcher, sse3).await; + mount_sse_once_match(&server, third_matcher, sse3).await; // Build config pointing to the mock server and spawn Codex. let model_provider = ModelProviderInfo { diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 73ad091fc9..690e1aab4d 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -25,7 +25,7 @@ use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -702,13 +702,13 @@ async fn mount_initial_flow(server: &MockServer) { && !body.contains("\"text\":\"AFTER_RESUME\"") && !body.contains("\"text\":\"AFTER_FORK\"") }; - mount_sse_once(server, match_first, sse1).await; + mount_sse_once_match(server, match_first, sse1).await; let match_compact = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains("You have exceeded the maximum number of tokens") }; - mount_sse_once(server, match_compact, sse2).await; + mount_sse_once_match(server, match_compact, sse2).await; let match_after_compact = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); @@ -716,19 +716,19 @@ async fn mount_initial_flow(server: &MockServer) { && !body.contains("\"text\":\"AFTER_RESUME\"") && !body.contains("\"text\":\"AFTER_FORK\"") }; - mount_sse_once(server, match_after_compact, sse3).await; + mount_sse_once_match(server, match_after_compact, sse3).await; let match_after_resume = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains("\"text\":\"AFTER_RESUME\"") }; - mount_sse_once(server, match_after_resume, sse4).await; + mount_sse_once_match(server, match_after_resume, sse4).await; let match_after_fork = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains("\"text\":\"AFTER_FORK\"") }; - mount_sse_once(server, match_after_fork, sse5).await; + mount_sse_once_match(server, match_after_fork, sse5).await; } async fn mount_second_compact_flow(server: &MockServer) { @@ -743,13 +743,13 @@ async fn mount_second_compact_flow(server: &MockServer) { body.contains("You have exceeded the maximum number of tokens") && body.contains("AFTER_FORK") }; - mount_sse_once(server, match_second_compact, sse6).await; + mount_sse_once_match(server, match_second_compact, sse6).await; let match_after_second_resume = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(&format!("\"text\":\"{AFTER_SECOND_RESUME}\"")) }; - mount_sse_once(server, match_after_second_resume, sse7).await; + mount_sse_once_match(server, match_after_second_resume, sse7).await; } async fn start_test_conversation( diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 90d9223208..e8d91fae8b 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -67,7 +67,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { && format.get("strict") == Some(&serde_json::Value::Bool(true)) && format.get("schema") == Some(&expected_schema) }; - responses::mount_sse_once(&server, match_json_text_param, sse1).await; + responses::mount_sse_once_match(&server, match_json_text_param, sse1).await; let TestCodex { codex, cwd, .. } = test_codex().build(&server).await?; diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 5dd2cb673e..b3a90ff339 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -12,6 +12,7 @@ mod fork_conversation; mod json_result; mod live_cli; mod model_overrides; +mod otel; mod prompt_caching; mod review; mod rmcp_client; diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs new file mode 100644 index 0000000000..2e7fe31794 --- /dev/null +++ b/codex-rs/core/tests/suite/otel.rs @@ -0,0 +1,1196 @@ +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InputItem; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_custom_tool_call; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::mount_sse; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event_with_timeout; +use std::time::Duration; +use tracing_test::traced_test; + +use core_test_support::responses::ev_local_shell_call; + +#[tokio::test] +#[traced_test] +async fn responses_api_emits_api_request_event() { + let server = start_mock_server().await; + + mount_sse_once(&server, sse(vec![ev_completed("done")])).await; + + let TestCodex { codex, .. } = test_codex().build(&server).await.unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TaskComplete(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| line.contains("codex.api_request")) + .map(|_| Ok(())) + .unwrap_or_else(|| Err("expected codex.api_request event".to_string())) + }); + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| line.contains("codex.conversation_starts")) + .map(|_| Ok(())) + .unwrap_or_else(|| Err("expected codex.conversation_starts event".to_string())) + }); +} + +#[tokio::test] +#[traced_test] +async fn process_sse_emits_tracing_for_output_item() { + let server = start_mock_server().await; + + mount_sse_once( + &server, + sse(vec![ev_assistant_message("id1", "hi"), ev_completed("id2")]), + ) + .await; + + let TestCodex { codex, .. } = test_codex().build(&server).await.unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TaskComplete(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| { + line.contains("codex.sse_event") + && line.contains("event.kind=response.output_item.done") + }) + .map(|_| Ok(())) + .unwrap_or(Err("missing response.output_item.done event".to_string())) + }); +} + +#[tokio::test] +#[traced_test] +async fn process_sse_emits_failed_event_on_parse_error() { + let server = start_mock_server().await; + + mount_sse_once(&server, "data: not-json\n\n".to_string()).await; + + let TestCodex { codex, .. } = test_codex() + .with_config(move |config| { + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TaskComplete(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| { + line.contains("codex.sse_event") + && line.contains("error.message") + && line.contains("expected ident at line 1 column 2") + }) + .map(|_| Ok(())) + .unwrap_or(Err("missing codex.sse_event".to_string())) + }); +} + +#[tokio::test] +#[traced_test] +async fn process_sse_records_failed_event_when_stream_closes_without_completed() { + let server = start_mock_server().await; + + mount_sse_once(&server, sse(vec![ev_assistant_message("id", "hi")])).await; + + let TestCodex { codex, .. } = test_codex() + .with_config(move |config| { + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TaskComplete(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| { + line.contains("codex.sse_event") + && line.contains("error.message") + && line.contains("stream closed before response.completed") + }) + .map(|_| Ok(())) + .unwrap_or(Err("missing codex.sse_event".to_string())) + }); +} + +#[tokio::test] +#[traced_test] +async fn process_sse_failed_event_records_response_error_message() { + let server = start_mock_server().await; + + mount_sse_once( + &server, + sse(vec![serde_json::json!({ + "type": "response.failed", + "response": { + "error": { + "message": "boom", + "code": "bad" + } + } + })]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(move |config| { + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TaskComplete(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| { + line.contains("codex.sse_event") + && line.contains("event.kind=response.failed") + && line.contains("error.message") + && line.contains("boom") + }) + .map(|_| Ok(())) + .unwrap_or(Err("missing codex.sse_event".to_string())) + }); +} + +#[tokio::test] +#[traced_test] +async fn process_sse_failed_event_logs_parse_error() { + let server = start_mock_server().await; + + mount_sse_once( + &server, + sse(vec![serde_json::json!({ + "type": "response.failed", + "response": { + "error": "not-an-object" + } + })]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(move |config| { + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TaskComplete(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| { + line.contains("codex.sse_event") && line.contains("event.kind=response.failed") + }) + .map(|_| Ok(())) + .unwrap_or(Err("missing codex.sse_event".to_string())) + }); +} + +#[tokio::test] +#[traced_test] +async fn process_sse_failed_event_logs_missing_error() { + let server = start_mock_server().await; + + mount_sse_once( + &server, + sse(vec![serde_json::json!({ + "type": "response.failed", + "response": {} + })]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(move |config| { + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TaskComplete(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| { + line.contains("codex.sse_event") && line.contains("event.kind=response.failed") + }) + .map(|_| Ok(())) + .unwrap_or(Err("missing codex.sse_event".to_string())) + }); +} + +#[tokio::test] +#[traced_test] +async fn process_sse_failed_event_logs_response_completed_parse_error() { + let server = start_mock_server().await; + + mount_sse_once( + &server, + sse(vec![serde_json::json!({ + "type": "response.completed", + "response": {} + })]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(move |config| { + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TaskComplete(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| { + line.contains("codex.sse_event") + && line.contains("event.kind=response.completed") + && line.contains("error.message") + && line.contains("failed to parse ResponseCompleted") + }) + .map(|_| Ok(())) + .unwrap_or(Err("missing codex.sse_event".to_string())) + }); +} + +#[tokio::test] +#[traced_test] +async fn process_sse_emits_completed_telemetry() { + let server = start_mock_server().await; + + mount_sse_once( + &server, + sse(vec![serde_json::json!({ + "type": "response.completed", + "response": { + "id": "resp1", + "usage": { + "input_tokens": 3, + "input_tokens_details": { "cached_tokens": 1 }, + "output_tokens": 5, + "output_tokens_details": { "reasoning_tokens": 2 }, + "total_tokens": 9 + } + } + })]), + ) + .await; + + let TestCodex { codex, .. } = test_codex().build(&server).await.unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TaskComplete(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| { + line.contains("codex.sse_event") + && line.contains("event.kind=response.completed") + && line.contains("input_token_count=3") + && line.contains("output_token_count=5") + && line.contains("cached_token_count=1") + && line.contains("reasoning_token_count=2") + && line.contains("tool_token_count=9") + }) + .map(|_| Ok(())) + .unwrap_or(Err("missing response.completed telemetry".to_string())) + }); +} + +#[tokio::test] +#[traced_test] +async fn handle_response_item_records_tool_result_for_custom_tool_call() { + let server = start_mock_server().await; + + mount_sse( + &server, + sse(vec![ + ev_custom_tool_call( + "custom-tool-call", + "unsupported_tool", + "{\"key\":\"value\"}", + ), + ev_completed("done"), + ]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(move |config| { + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TokenCount(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + let line = lines + .iter() + .find(|line| { + line.contains("codex.tool_result") && line.contains("call_id=custom-tool-call") + }) + .ok_or_else(|| "missing codex.tool_result event".to_string())?; + + if !line.contains("tool_name=unsupported_tool") { + return Err("missing tool_name field".to_string()); + } + if !line.contains("arguments={\"key\":\"value\"}") { + return Err("missing arguments field".to_string()); + } + if !line.contains("output=unsupported custom tool call: unsupported_tool") { + return Err("missing output field".to_string()); + } + if !line.contains("success=false") { + return Err("missing success field".to_string()); + } + + Ok(()) + }); +} + +#[tokio::test] +#[traced_test] +async fn handle_response_item_records_tool_result_for_function_call() { + let server = start_mock_server().await; + + mount_sse( + &server, + sse(vec![ + ev_function_call("function-call", "nonexistent", "{\"value\":1}"), + ev_completed("done"), + ]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(move |config| { + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TokenCount(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + let line = lines + .iter() + .find(|line| { + line.contains("codex.tool_result") && line.contains("call_id=function-call") + }) + .ok_or_else(|| "missing codex.tool_result event".to_string())?; + + if !line.contains("tool_name=nonexistent") { + return Err("missing tool_name field".to_string()); + } + if !line.contains("arguments={\"value\":1}") { + return Err("missing arguments field".to_string()); + } + if !line.contains("output=unsupported call: nonexistent") { + return Err("missing output field".to_string()); + } + if !line.contains("success=false") { + return Err("missing success field".to_string()); + } + + Ok(()) + }); +} + +#[tokio::test] +#[traced_test] +async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() { + let server = start_mock_server().await; + + mount_sse( + &server, + sse(vec![ + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "local_shell_call", + "status": "completed", + "action": { + "type": "exec", + "command": vec!["/bin/echo", "hello"], + } + } + }), + ev_completed("done"), + ]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(move |config| { + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TokenCount(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + let line = lines + .iter() + .find(|line| { + line.contains("codex.tool_result") + && line.contains(&"tool_name=local_shell".to_string()) + && line.contains("output=LocalShellCall without call_id or id") + }) + .ok_or_else(|| "missing codex.tool_result event".to_string())?; + + if !line.contains("success=false") { + return Err("missing success field".to_string()); + } + + Ok(()) + }); +} + +#[cfg(target_os = "macos")] +#[tokio::test] +#[traced_test] +async fn handle_response_item_records_tool_result_for_local_shell_call() { + let server = start_mock_server().await; + + mount_sse( + &server, + sse(vec![ + ev_local_shell_call("shell-call", "completed", vec!["/bin/echo", "shell"]), + ev_completed("done"), + ]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(move |config| { + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TokenCount(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(|lines: &[&str]| { + let line = lines + .iter() + .find(|line| line.contains("codex.tool_result") && line.contains("call_id=shell-call")) + .ok_or_else(|| "missing codex.tool_result event".to_string())?; + + if !line.contains("tool_name=local_shell") { + return Err("missing tool_name field".to_string()); + } + if !line.contains("arguments=/bin/echo shell") { + return Err("missing arguments field".to_string()); + } + let output_idx = line + .find("output=") + .ok_or_else(|| "missing output field".to_string())?; + if line[output_idx + "output=".len()..].is_empty() { + return Err("empty output field".to_string()); + } + if !line.contains("success=false") { + return Err("missing success field".to_string()); + } + + Ok(()) + }); +} + +fn tool_decision_assertion<'a>( + call_id: &'a str, + expected_decision: &'a str, + expected_source: &'a str, +) -> impl Fn(&[&str]) -> Result<(), String> + 'a { + let call_id = call_id.to_string(); + let expected_decision = expected_decision.to_string(); + let expected_source = expected_source.to_string(); + + move |lines: &[&str]| { + let line = lines + .iter() + .find(|line| { + line.contains("codex.tool_decision") && line.contains(&format!("call_id={call_id}")) + }) + .ok_or_else(|| format!("missing codex.tool_decision event for {call_id}"))?; + + let lower = line.to_lowercase(); + if !lower.contains("tool_name=local_shell") { + return Err("missing tool_name for local_shell".to_string()); + } + if !lower.contains(&format!("decision={expected_decision}")) { + return Err(format!("unexpected decision for {call_id}")); + } + if !lower.contains(&format!("source={expected_source}")) { + return Err(format!("unexpected source for {expected_source}")); + } + + Ok(()) + } +} + +#[tokio::test] +#[traced_test] +async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { + let server = start_mock_server().await; + mount_sse( + &server, + sse(vec![ + ev_local_shell_call("auto_config_call", "completed", vec!["/bin/echo", "hello"]), + ev_completed("done"), + ]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(|config| { + config.approval_policy = AskForApproval::OnRequest; + config.sandbox_policy = SandboxPolicy::DangerFullAccess; + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TokenCount(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(tool_decision_assertion( + "auto_config_call", + "approved", + "config", + )); +} + +#[tokio::test] +#[traced_test] +async fn handle_container_exec_user_approved_records_tool_decision() { + let server = start_mock_server().await; + mount_sse( + &server, + sse(vec![ + ev_local_shell_call( + "user_approved_call", + "completed", + vec!["/bin/echo", "approved"], + ), + ev_completed("done"), + ]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(|config| { + config.approval_policy = AskForApproval::UnlessTrusted; + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "approved".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::ExecApprovalRequest(_)), + Duration::from_secs(5), + ) + .await; + + codex + .submit(Op::ExecApproval { + id: "0".into(), + decision: ReviewDecision::Approved, + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TokenCount(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(tool_decision_assertion( + "user_approved_call", + "approved", + "user", + )); +} + +#[tokio::test] +#[traced_test] +async fn handle_container_exec_user_approved_for_session_records_tool_decision() { + let server = start_mock_server().await; + + mount_sse( + &server, + sse(vec![ + ev_local_shell_call( + "user_approved_session_call", + "completed", + vec!["/bin/echo", "persist"], + ), + ev_completed("done"), + ]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(|config| { + config.approval_policy = AskForApproval::UnlessTrusted; + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "persist".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::ExecApprovalRequest(_)), + Duration::from_secs(5), + ) + .await; + + codex + .submit(Op::ExecApproval { + id: "0".into(), + decision: ReviewDecision::ApprovedForSession, + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TokenCount(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(tool_decision_assertion( + "user_approved_session_call", + "approvedforsession", + "user", + )); +} + +#[tokio::test] +#[traced_test] +async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { + let server = start_mock_server().await; + + mount_sse( + &server, + sse(vec![ + ev_local_shell_call( + "sandbox_retry_call", + "completed", + vec!["/bin/echo", "retry"], + ), + ev_completed("done"), + ]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(|config| { + config.approval_policy = AskForApproval::UnlessTrusted; + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "retry".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::ExecApprovalRequest(_)), + Duration::from_secs(5), + ) + .await; + + codex + .submit(Op::ExecApproval { + id: "0".into(), + decision: ReviewDecision::Approved, + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TokenCount(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(tool_decision_assertion( + "sandbox_retry_call", + "approved", + "user", + )); +} + +#[tokio::test] +#[traced_test] +async fn handle_container_exec_user_denies_records_tool_decision() { + let server = start_mock_server().await; + + mount_sse( + &server, + sse(vec![ + ev_local_shell_call("user_denied_call", "completed", vec!["/bin/echo", "deny"]), + ev_completed("done"), + ]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(|config| { + config.approval_policy = AskForApproval::UnlessTrusted; + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "deny".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::ExecApprovalRequest(_)), + Duration::from_secs(5), + ) + .await; + + codex + .submit(Op::ExecApproval { + id: "0".into(), + decision: ReviewDecision::Denied, + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TokenCount(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(tool_decision_assertion( + "user_denied_call", + "denied", + "user", + )); +} + +#[tokio::test] +#[traced_test] +async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() { + let server = start_mock_server().await; + + mount_sse( + &server, + sse(vec![ + ev_local_shell_call( + "sandbox_session_call", + "completed", + vec!["/bin/echo", "persist"], + ), + ev_completed("done"), + ]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(|config| { + config.approval_policy = AskForApproval::UnlessTrusted; + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "persist".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::ExecApprovalRequest(_)), + Duration::from_secs(5), + ) + .await; + + codex + .submit(Op::ExecApproval { + id: "0".into(), + decision: ReviewDecision::ApprovedForSession, + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TokenCount(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(tool_decision_assertion( + "sandbox_session_call", + "approvedforsession", + "user", + )); +} + +#[tokio::test] +#[traced_test] +async fn handle_sandbox_error_user_denies_records_tool_decision() { + let server = start_mock_server().await; + + mount_sse( + &server, + sse(vec![ + ev_local_shell_call("sandbox_deny_call", "completed", vec!["/bin/echo", "deny"]), + ev_completed("done"), + ]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_config(|config| { + config.approval_policy = AskForApproval::UnlessTrusted; + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + }) + .build(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "deny".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::ExecApprovalRequest(_)), + Duration::from_secs(5), + ) + .await; + + codex + .submit(Op::ExecApproval { + id: "0".into(), + decision: ReviewDecision::Denied, + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::TokenCount(_)), + Duration::from_secs(5), + ) + .await; + + logs_assert(tool_decision_assertion( + "sandbox_deny_call", + "denied", + "user", + )); +} diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index aed84ec603..bd1682406d 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -12,7 +12,7 @@ use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use core_test_support::responses; -use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_once_match; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -36,7 +36,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { let server_name = "rmcp"; let tool_name = format!("{server_name}__echo"); - mount_sse_once( + mount_sse_once_match( &server, any(), responses::sse(vec![ @@ -49,7 +49,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { ]), ) .await; - mount_sse_once( + mount_sse_once_match( &server, any(), responses::sse(vec![ @@ -173,7 +173,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { let server_name = "rmcp_http"; let tool_name = format!("{server_name}__echo"); - mount_sse_once( + mount_sse_once_match( &server, any(), responses::sse(vec![ @@ -186,7 +186,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { ]), ) .await; - mount_sse_once( + mount_sse_once_match( &server, any(), responses::sse(vec![ diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index c162239407..2ad87c7b6c 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -28,7 +28,7 @@ async fn summarize_context_three_requests_and_instructions() -> anyhow::Result<( let sse1 = sse(vec![ev_assistant_message("m1", "Done"), ev_completed("r1")]); - responses::mount_sse_once(&server, any(), sse1).await; + responses::mount_sse_once_match(&server, any(), sse1).await; let notify_dir = TempDir::new()?; // write a script to the notify that touches a file next to it diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 8f0c279058..1291d3ee9f 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -3,7 +3,7 @@ This document describes Codex’s experimental MCP interface: a JSON‑RPC API that runs over the Model Context Protocol (MCP) transport to control a local Codex engine. - Status: experimental and subject to change without notice -- Server binary: `codex mcp` (or `codex-mcp-server`) +- Server binary: `codex mcp-server` (or `codex-mcp-server`) - Transport: standard MCP over stdio (JSON‑RPC 2.0, line‑delimited) ## Overview @@ -36,15 +36,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: diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 8603e8fd89..27ae067c92 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -40,12 +40,14 @@ tokio = { workspace = true, features = [ ] } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter"] } +opentelemetry-appender-tracing = { workspace = true } ts-rs = { workspace = true, features = [ "uuid-impl", "serde-json-impl", "no-serde-warnings", ] } + [dev-dependencies] assert_cmd = { workspace = true } core_test_support = { workspace = true } @@ -56,3 +58,4 @@ tempfile = { workspace = true } uuid = { workspace = true } walkdir = { workspace = true } wiremock = { workspace = true } +mcp-types = { workspace = true } diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index 07a9c01586..f52071d4d3 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -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, diff --git a/codex-rs/exec/src/experimental_event_processor_with_json_output.rs b/codex-rs/exec/src/experimental_event_processor_with_json_output.rs index 55af87a20b..164ebecefa 100644 --- a/codex-rs/exec/src/experimental_event_processor_with_json_output.rs +++ b/codex-rs/exec/src/experimental_event_processor_with_json_output.rs @@ -8,22 +8,25 @@ use crate::event_processor::handle_last_message; use crate::exec_events::AssistantMessageItem; use crate::exec_events::CommandExecutionItem; use crate::exec_events::CommandExecutionStatus; -use crate::exec_events::ConversationErrorEvent; -use crate::exec_events::ConversationEvent; -use crate::exec_events::ConversationItem; -use crate::exec_events::ConversationItemDetails; use crate::exec_events::FileChangeItem; use crate::exec_events::FileUpdateChange; use crate::exec_events::ItemCompletedEvent; use crate::exec_events::ItemStartedEvent; use crate::exec_events::ItemUpdatedEvent; +use crate::exec_events::McpToolCallItem; +use crate::exec_events::McpToolCallStatus; use crate::exec_events::PatchApplyStatus; use crate::exec_events::PatchChangeKind; use crate::exec_events::ReasoningItem; -use crate::exec_events::SessionCreatedEvent; +use crate::exec_events::ThreadErrorEvent; +use crate::exec_events::ThreadEvent; +use crate::exec_events::ThreadItem; +use crate::exec_events::ThreadItemDetails; +use crate::exec_events::ThreadStartedEvent; use crate::exec_events::TodoItem; use crate::exec_events::TodoListItem; use crate::exec_events::TurnCompletedEvent; +use crate::exec_events::TurnFailedEvent; use crate::exec_events::TurnStartedEvent; use crate::exec_events::Usage; use codex_core::config::Config; @@ -36,6 +39,8 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::FileChange; +use codex_core::protocol::McpToolCallBeginEvent; +use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; @@ -53,6 +58,8 @@ pub struct ExperimentalEventProcessorWithJsonOutput { // Tracks the todo list for the current turn (at most one per turn). running_todo_list: Option, last_total_token_usage: Option, + running_mcp_tool_calls: HashMap, + last_critical_error: Option, } #[derive(Debug, Clone)] @@ -67,6 +74,13 @@ struct RunningTodoList { items: Vec, } +#[derive(Debug, Clone)] +struct RunningMcpToolCall { + server: String, + tool: String, + item_id: String, +} + impl ExperimentalEventProcessorWithJsonOutput { pub fn new(last_message_path: Option) -> Self { Self { @@ -76,16 +90,20 @@ impl ExperimentalEventProcessorWithJsonOutput { running_patch_applies: HashMap::new(), running_todo_list: None, last_total_token_usage: None, + running_mcp_tool_calls: HashMap::new(), + last_critical_error: None, } } - pub fn collect_conversation_events(&mut self, event: &Event) -> Vec { + pub fn collect_thread_events(&mut self, event: &Event) -> Vec { match &event.msg { EventMsg::SessionConfigured(ev) => self.handle_session_configured(ev), EventMsg::AgentMessage(ev) => self.handle_agent_message(ev), EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev), + EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev), + EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev), EventMsg::TokenCount(ev) => { @@ -96,10 +114,14 @@ impl ExperimentalEventProcessorWithJsonOutput { } EventMsg::TaskStarted(ev) => self.handle_task_started(ev), EventMsg::TaskComplete(_) => self.handle_task_complete(), - EventMsg::Error(ev) => vec![ConversationEvent::Error(ConversationErrorEvent { - message: ev.message.clone(), - })], - EventMsg::StreamError(ev) => vec![ConversationEvent::Error(ConversationErrorEvent { + EventMsg::Error(ev) => { + let error = ThreadErrorEvent { + message: ev.message.clone(), + }; + self.last_critical_error = Some(error.clone()); + vec![ThreadEvent::Error(error)] + } + EventMsg::StreamError(ev) => vec![ThreadEvent::Error(ThreadErrorEvent { message: ev.message.clone(), })], EventMsg::PlanUpdate(ev) => self.handle_plan_update(ev), @@ -115,43 +137,36 @@ impl ExperimentalEventProcessorWithJsonOutput { ) } - fn handle_session_configured( - &self, - payload: &SessionConfiguredEvent, - ) -> Vec { - vec![ConversationEvent::SessionCreated(SessionCreatedEvent { - session_id: payload.session_id.to_string(), + fn handle_session_configured(&self, payload: &SessionConfiguredEvent) -> Vec { + vec![ThreadEvent::ThreadStarted(ThreadStartedEvent { + thread_id: payload.session_id.to_string(), })] } - fn handle_agent_message(&self, payload: &AgentMessageEvent) -> Vec { - let item = ConversationItem { + fn handle_agent_message(&self, payload: &AgentMessageEvent) -> Vec { + let item = ThreadItem { id: self.get_next_item_id(), - details: ConversationItemDetails::AssistantMessage(AssistantMessageItem { + details: ThreadItemDetails::AssistantMessage(AssistantMessageItem { text: payload.message.clone(), }), }; - vec![ConversationEvent::ItemCompleted(ItemCompletedEvent { - item, - })] + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] } - fn handle_reasoning_event(&self, ev: &AgentReasoningEvent) -> Vec { - let item = ConversationItem { + fn handle_reasoning_event(&self, ev: &AgentReasoningEvent) -> Vec { + let item = ThreadItem { id: self.get_next_item_id(), - details: ConversationItemDetails::Reasoning(ReasoningItem { + details: ThreadItemDetails::Reasoning(ReasoningItem { text: ev.text.clone(), }), }; - vec![ConversationEvent::ItemCompleted(ItemCompletedEvent { - item, - })] + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] } - fn handle_exec_command_begin(&mut self, ev: &ExecCommandBeginEvent) -> Vec { + fn handle_exec_command_begin(&mut self, ev: &ExecCommandBeginEvent) -> Vec { let item_id = self.get_next_item_id(); let command_string = match shlex::try_join(ev.command.iter().map(String::as_str)) { @@ -173,9 +188,9 @@ impl ExperimentalEventProcessorWithJsonOutput { }, ); - let item = ConversationItem { + let item = ThreadItem { id: item_id, - details: ConversationItemDetails::CommandExecution(CommandExecutionItem { + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { command: command_string, aggregated_output: String::new(), exit_code: None, @@ -183,10 +198,70 @@ impl ExperimentalEventProcessorWithJsonOutput { }), }; - vec![ConversationEvent::ItemStarted(ItemStartedEvent { item })] + vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] } - fn handle_patch_apply_begin(&mut self, ev: &PatchApplyBeginEvent) -> Vec { + fn handle_mcp_tool_call_begin(&mut self, ev: &McpToolCallBeginEvent) -> Vec { + let item_id = self.get_next_item_id(); + let server = ev.invocation.server.clone(); + let tool = ev.invocation.tool.clone(); + + self.running_mcp_tool_calls.insert( + ev.call_id.clone(), + RunningMcpToolCall { + server: server.clone(), + tool: tool.clone(), + item_id: item_id.clone(), + }, + ); + + let item = ThreadItem { + id: item_id, + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server, + tool, + status: McpToolCallStatus::InProgress, + }), + }; + + vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] + } + + fn handle_mcp_tool_call_end(&mut self, ev: &McpToolCallEndEvent) -> Vec { + let status = if ev.is_success() { + McpToolCallStatus::Completed + } else { + McpToolCallStatus::Failed + }; + + let (server, tool, item_id) = match self.running_mcp_tool_calls.remove(&ev.call_id) { + Some(running) => (running.server, running.tool, running.item_id), + None => { + warn!( + call_id = ev.call_id, + "Received McpToolCallEnd without begin; synthesizing new item" + ); + ( + ev.invocation.server.clone(), + ev.invocation.tool.clone(), + self.get_next_item_id(), + ) + } + }; + + let item = ThreadItem { + id: item_id, + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server, + tool, + status, + }), + }; + + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] + } + + fn handle_patch_apply_begin(&mut self, ev: &PatchApplyBeginEvent) -> Vec { self.running_patch_applies .insert(ev.call_id.clone(), ev.clone()); @@ -201,17 +276,17 @@ impl ExperimentalEventProcessorWithJsonOutput { } } - fn handle_patch_apply_end(&mut self, ev: &PatchApplyEndEvent) -> Vec { + fn handle_patch_apply_end(&mut self, ev: &PatchApplyEndEvent) -> Vec { if let Some(running_patch_apply) = self.running_patch_applies.remove(&ev.call_id) { let status = if ev.success { PatchApplyStatus::Completed } else { PatchApplyStatus::Failed }; - let item = ConversationItem { + let item = ThreadItem { id: self.get_next_item_id(), - details: ConversationItemDetails::FileChange(FileChangeItem { + details: ThreadItemDetails::FileChange(FileChangeItem { changes: running_patch_apply .changes .iter() @@ -224,15 +299,13 @@ impl ExperimentalEventProcessorWithJsonOutput { }), }; - return vec![ConversationEvent::ItemCompleted(ItemCompletedEvent { - item, - })]; + return vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]; } Vec::new() } - fn handle_exec_command_end(&mut self, ev: &ExecCommandEndEvent) -> Vec { + fn handle_exec_command_end(&mut self, ev: &ExecCommandEndEvent) -> Vec { let Some(RunningCommand { command, item_id }) = self.running_commands.remove(&ev.call_id) else { warn!( @@ -246,10 +319,10 @@ impl ExperimentalEventProcessorWithJsonOutput { } else { CommandExecutionStatus::Failed }; - let item = ConversationItem { + let item = ThreadItem { id: item_id, - details: ConversationItemDetails::CommandExecution(CommandExecutionItem { + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { command, aggregated_output: ev.aggregated_output.clone(), exit_code: Some(ev.exit_code), @@ -257,9 +330,7 @@ impl ExperimentalEventProcessorWithJsonOutput { }), }; - vec![ConversationEvent::ItemCompleted(ItemCompletedEvent { - item, - })] + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] } fn todo_items_from_plan(&self, args: &UpdatePlanArgs) -> Vec { @@ -272,16 +343,16 @@ impl ExperimentalEventProcessorWithJsonOutput { .collect() } - fn handle_plan_update(&mut self, args: &UpdatePlanArgs) -> Vec { + fn handle_plan_update(&mut self, args: &UpdatePlanArgs) -> Vec { let items = self.todo_items_from_plan(args); if let Some(running) = &mut self.running_todo_list { running.items = items.clone(); - let item = ConversationItem { + let item = ThreadItem { id: running.item_id.clone(), - details: ConversationItemDetails::TodoList(TodoListItem { items }), + details: ThreadItemDetails::TodoList(TodoListItem { items }), }; - return vec![ConversationEvent::ItemUpdated(ItemUpdatedEvent { item })]; + return vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent { item })]; } let item_id = self.get_next_item_id(); @@ -289,18 +360,19 @@ impl ExperimentalEventProcessorWithJsonOutput { item_id: item_id.clone(), items: items.clone(), }); - let item = ConversationItem { + let item = ThreadItem { id: item_id, - details: ConversationItemDetails::TodoList(TodoListItem { items }), + details: ThreadItemDetails::TodoList(TodoListItem { items }), }; - vec![ConversationEvent::ItemStarted(ItemStartedEvent { item })] + vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] } - fn handle_task_started(&self, _: &TaskStartedEvent) -> Vec { - vec![ConversationEvent::TurnStarted(TurnStartedEvent {})] + fn handle_task_started(&mut self, _: &TaskStartedEvent) -> Vec { + self.last_critical_error = None; + vec![ThreadEvent::TurnStarted(TurnStartedEvent {})] } - fn handle_task_complete(&mut self) -> Vec { + fn handle_task_complete(&mut self) -> Vec { let usage = if let Some(u) = &self.last_total_token_usage { Usage { input_tokens: u.input_tokens, @@ -314,20 +386,20 @@ impl ExperimentalEventProcessorWithJsonOutput { let mut items = Vec::new(); if let Some(running) = self.running_todo_list.take() { - let item = ConversationItem { + let item = ThreadItem { id: running.item_id, - details: ConversationItemDetails::TodoList(TodoListItem { + details: ThreadItemDetails::TodoList(TodoListItem { items: running.items, }), }; - items.push(ConversationEvent::ItemCompleted(ItemCompletedEvent { - item, - })); + items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); } - items.push(ConversationEvent::TurnCompleted(TurnCompletedEvent { - usage, - })); + if let Some(error) = self.last_critical_error.take() { + items.push(ThreadEvent::TurnFailed(TurnFailedEvent { error })); + } else { + items.push(ThreadEvent::TurnCompleted(TurnCompletedEvent { usage })); + } items } @@ -342,7 +414,7 @@ impl EventProcessor for ExperimentalEventProcessorWithJsonOutput { } fn process_event(&mut self, event: Event) -> CodexStatus { - let aggregated = self.collect_conversation_events(&event); + let aggregated = self.collect_thread_events(&event); for conv_event in aggregated { match serde_json::to_string(&conv_event) { Ok(line) => { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index da23fb1b23..bdf0365026 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -5,10 +5,6 @@ pub mod event_processor_with_json_output; pub mod exec_events; pub mod experimental_event_processor_with_json_output; -use std::io::IsTerminal; -use std::io::Read; -use std::path::PathBuf; - pub use cli::Cli; use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; @@ -26,20 +22,30 @@ use codex_core::protocol::TaskCompleteEvent; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; use event_processor_with_human_output::EventProcessorWithHumanOutput; +use event_processor_with_json_output::EventProcessorWithJsonOutput; use experimental_event_processor_with_json_output::ExperimentalEventProcessorWithJsonOutput; +use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; use serde_json::Value; +use std::io::IsTerminal; +use std::io::Read; +use std::path::PathBuf; use tracing::debug; use tracing::error; use tracing::info; use tracing_subscriber::EnvFilter; +use tracing_subscriber::prelude::*; use crate::cli::Command as ExecCommand; use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; -use crate::event_processor_with_json_output::EventProcessorWithJsonOutput; +use codex_core::default_client::set_default_originator; use codex_core::find_conversation_path_by_id_str; pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { + if let Err(err) = set_default_originator("codex_exec") { + tracing::warn!(?err, "Failed to set codex exec originator override {err:?}"); + } + let Cli { command, images, @@ -114,19 +120,18 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any ), }; - // TODO(mbolin): Take a more thoughtful approach to logging. + // Build fmt layer (existing logging) to compose with OTEL layer. let default_level = "error"; - let _ = tracing_subscriber::fmt() - // Fallback to the `default_level` log filter if the environment - // variable is not set _or_ contains an invalid value - .with_env_filter( - EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new(default_level)) - .unwrap_or_else(|_| EnvFilter::new(default_level)), - ) + + // Build env_filter separately and attach via with_filter. + let env_filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(default_level)) + .unwrap_or_else(|_| EnvFilter::new(default_level)); + + let fmt_layer = tracing_subscriber::fmt::layer() .with_ansi(stderr_with_ansi) .with_writer(std::io::stderr) - .try_init(); + .with_filter(env_filter); let sandbox_mode = if full_auto { Some(SandboxMode::WorkspaceWrite) @@ -182,6 +187,31 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any }; let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?; + + let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION")); + + #[allow(clippy::print_stderr)] + let otel = match otel { + Ok(otel) => otel, + Err(e) => { + eprintln!("Could not create otel exporter: {e}"); + std::process::exit(1); + } + }; + + if let Some(provider) = otel.as_ref() { + let otel_layer = OpenTelemetryTracingBridge::new(&provider.logger).with_filter( + tracing_subscriber::filter::filter_fn(codex_core::otel_init::codex_export_filter), + ); + + let _ = tracing_subscriber::registry() + .with(fmt_layer) + .with(otel_layer) + .try_init(); + } else { + let _ = tracing_subscriber::registry().with(fmt_layer).try_init(); + } + let mut event_processor: Box = match (json_mode, experimental_json) { (_, true) => Box::new(ExperimentalEventProcessorWithJsonOutput::new( last_message_file.clone(), diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index ebfb667bfb..c462879767 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -1,33 +1,41 @@ use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningEvent; +use codex_core::protocol::ErrorEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::FileChange; +use codex_core::protocol::McpInvocation; +use codex_core::protocol::McpToolCallBeginEvent; +use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; use codex_exec::exec_events::AssistantMessageItem; use codex_exec::exec_events::CommandExecutionItem; use codex_exec::exec_events::CommandExecutionStatus; -use codex_exec::exec_events::ConversationErrorEvent; -use codex_exec::exec_events::ConversationEvent; -use codex_exec::exec_events::ConversationItem; -use codex_exec::exec_events::ConversationItemDetails; use codex_exec::exec_events::ItemCompletedEvent; use codex_exec::exec_events::ItemStartedEvent; use codex_exec::exec_events::ItemUpdatedEvent; +use codex_exec::exec_events::McpToolCallItem; +use codex_exec::exec_events::McpToolCallStatus; use codex_exec::exec_events::PatchApplyStatus; use codex_exec::exec_events::PatchChangeKind; use codex_exec::exec_events::ReasoningItem; -use codex_exec::exec_events::SessionCreatedEvent; +use codex_exec::exec_events::ThreadErrorEvent; +use codex_exec::exec_events::ThreadEvent; +use codex_exec::exec_events::ThreadItem; +use codex_exec::exec_events::ThreadItemDetails; +use codex_exec::exec_events::ThreadStartedEvent; use codex_exec::exec_events::TodoItem as ExecTodoItem; use codex_exec::exec_events::TodoListItem as ExecTodoListItem; use codex_exec::exec_events::TurnCompletedEvent; +use codex_exec::exec_events::TurnFailedEvent; use codex_exec::exec_events::TurnStartedEvent; use codex_exec::exec_events::Usage; use codex_exec::experimental_event_processor_with_json_output::ExperimentalEventProcessorWithJsonOutput; +use mcp_types::CallToolResult; use pretty_assertions::assert_eq; use std::path::PathBuf; use std::time::Duration; @@ -40,7 +48,7 @@ fn event(id: &str, msg: EventMsg) -> Event { } #[test] -fn session_configured_produces_session_created_event() { +fn session_configured_produces_thread_started_event() { let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); let session_id = codex_protocol::mcp_protocol::ConversationId::from_string( "67e55044-10b1-426f-9247-bb680e5fe0c8", @@ -59,11 +67,11 @@ fn session_configured_produces_session_created_event() { rollout_path, }), ); - let out = ep.collect_conversation_events(&ev); + let out = ep.collect_thread_events(&ev); assert_eq!( out, - vec![ConversationEvent::SessionCreated(SessionCreatedEvent { - session_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + vec![ThreadEvent::ThreadStarted(ThreadStartedEvent { + thread_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), })] ); } @@ -71,17 +79,14 @@ fn session_configured_produces_session_created_event() { #[test] fn task_started_produces_turn_started_event() { let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); - let out = ep.collect_conversation_events(&event( + let out = ep.collect_thread_events(&event( "t1", EventMsg::TaskStarted(codex_core::protocol::TaskStartedEvent { model_context_window: Some(32_000), }), )); - assert_eq!( - out, - vec![ConversationEvent::TurnStarted(TurnStartedEvent {})] - ); + assert_eq!(out, vec![ThreadEvent::TurnStarted(TurnStartedEvent {})]); } #[test] @@ -109,13 +114,13 @@ fn plan_update_emits_todo_list_started_updated_and_completed() { ], }), ); - let out_first = ep.collect_conversation_events(&first); + let out_first = ep.collect_thread_events(&first); assert_eq!( out_first, - vec![ConversationEvent::ItemStarted(ItemStartedEvent { - item: ConversationItem { + vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ThreadItem { id: "item_0".to_string(), - details: ConversationItemDetails::TodoList(ExecTodoListItem { + details: ThreadItemDetails::TodoList(ExecTodoListItem { items: vec![ ExecTodoItem { text: "step one".to_string(), @@ -148,13 +153,13 @@ fn plan_update_emits_todo_list_started_updated_and_completed() { ], }), ); - let out_second = ep.collect_conversation_events(&second); + let out_second = ep.collect_thread_events(&second); assert_eq!( out_second, - vec![ConversationEvent::ItemUpdated(ItemUpdatedEvent { - item: ConversationItem { + vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent { + item: ThreadItem { id: "item_0".to_string(), - details: ConversationItemDetails::TodoList(ExecTodoListItem { + details: ThreadItemDetails::TodoList(ExecTodoListItem { items: vec![ ExecTodoItem { text: "step one".to_string(), @@ -177,14 +182,14 @@ fn plan_update_emits_todo_list_started_updated_and_completed() { last_agent_message: None, }), ); - let out_complete = ep.collect_conversation_events(&complete); + let out_complete = ep.collect_thread_events(&complete); assert_eq!( out_complete, vec![ - ConversationEvent::ItemCompleted(ItemCompletedEvent { - item: ConversationItem { + ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { id: "item_0".to_string(), - details: ConversationItemDetails::TodoList(ExecTodoListItem { + details: ThreadItemDetails::TodoList(ExecTodoListItem { items: vec![ ExecTodoItem { text: "step one".to_string(), @@ -198,13 +203,116 @@ fn plan_update_emits_todo_list_started_updated_and_completed() { }), }, }), - ConversationEvent::TurnCompleted(TurnCompletedEvent { + ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default(), }), ] ); } +#[test] +fn mcp_tool_call_begin_and_end_emit_item_events() { + let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let invocation = McpInvocation { + server: "server_a".to_string(), + tool: "tool_x".to_string(), + arguments: None, + }; + + let begin = event( + "m1", + EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id: "call-1".to_string(), + invocation: invocation.clone(), + }), + ); + let begin_events = ep.collect_thread_events(&begin); + assert_eq!( + begin_events, + vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_a".to_string(), + tool: "tool_x".to_string(), + status: McpToolCallStatus::InProgress, + }), + }, + })] + ); + + let end = event( + "m2", + EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: "call-1".to_string(), + invocation, + duration: Duration::from_secs(1), + result: Ok(CallToolResult { + content: Vec::new(), + is_error: None, + structured_content: None, + }), + }), + ); + let end_events = ep.collect_thread_events(&end); + assert_eq!( + end_events, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_a".to_string(), + tool: "tool_x".to_string(), + status: McpToolCallStatus::Completed, + }), + }, + })] + ); +} + +#[test] +fn mcp_tool_call_failure_sets_failed_status() { + let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let invocation = McpInvocation { + server: "server_b".to_string(), + tool: "tool_y".to_string(), + arguments: None, + }; + + let begin = event( + "m3", + EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id: "call-2".to_string(), + invocation: invocation.clone(), + }), + ); + ep.collect_thread_events(&begin); + + let end = event( + "m4", + EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: "call-2".to_string(), + invocation, + duration: Duration::from_millis(5), + result: Err("tool exploded".to_string()), + }), + ); + let events = ep.collect_thread_events(&end); + assert_eq!( + events, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_b".to_string(), + tool: "tool_y".to_string(), + status: McpToolCallStatus::Failed, + }), + }, + })] + ); +} + #[test] fn plan_update_after_complete_starts_new_todo_list_with_new_id() { use codex_core::plan_tool::PlanItemArg; @@ -224,14 +332,14 @@ fn plan_update_after_complete_starts_new_todo_list_with_new_id() { }], }), ); - let _ = ep.collect_conversation_events(&start); + let _ = ep.collect_thread_events(&start); let complete = event( "t2", EventMsg::TaskComplete(codex_core::protocol::TaskCompleteEvent { last_agent_message: None, }), ); - let _ = ep.collect_conversation_events(&complete); + let _ = ep.collect_thread_events(&complete); // Second turn: a new todo list should have a new id let start_again = event( @@ -244,10 +352,10 @@ fn plan_update_after_complete_starts_new_todo_list_with_new_id() { }], }), ); - let out = ep.collect_conversation_events(&start_again); + let out = ep.collect_thread_events(&start_again); match &out[0] { - ConversationEvent::ItemStarted(ItemStartedEvent { item }) => { + ThreadEvent::ItemStarted(ItemStartedEvent { item }) => { assert_eq!(&item.id, "item_1"); } other => panic!("unexpected event: {other:?}"), @@ -263,13 +371,13 @@ fn agent_reasoning_produces_item_completed_reasoning() { text: "thinking...".to_string(), }), ); - let out = ep.collect_conversation_events(&ev); + let out = ep.collect_thread_events(&ev); assert_eq!( out, - vec![ConversationEvent::ItemCompleted(ItemCompletedEvent { - item: ConversationItem { + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { id: "item_0".to_string(), - details: ConversationItemDetails::Reasoning(ReasoningItem { + details: ThreadItemDetails::Reasoning(ReasoningItem { text: "thinking...".to_string(), }), }, @@ -286,13 +394,13 @@ fn agent_message_produces_item_completed_assistant_message() { message: "hello".to_string(), }), ); - let out = ep.collect_conversation_events(&ev); + let out = ep.collect_thread_events(&ev); assert_eq!( out, - vec![ConversationEvent::ItemCompleted(ItemCompletedEvent { - item: ConversationItem { + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { id: "item_0".to_string(), - details: ConversationItemDetails::AssistantMessage(AssistantMessageItem { + details: ThreadItemDetails::AssistantMessage(AssistantMessageItem { text: "hello".to_string(), }), }, @@ -303,7 +411,7 @@ fn agent_message_produces_item_completed_assistant_message() { #[test] fn error_event_produces_error() { let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); - let out = ep.collect_conversation_events(&event( + let out = ep.collect_thread_events(&event( "e1", EventMsg::Error(codex_core::protocol::ErrorEvent { message: "boom".to_string(), @@ -311,7 +419,7 @@ fn error_event_produces_error() { )); assert_eq!( out, - vec![ConversationEvent::Error(ConversationErrorEvent { + vec![ThreadEvent::Error(ThreadErrorEvent { message: "boom".to_string(), })] ); @@ -320,7 +428,7 @@ fn error_event_produces_error() { #[test] fn stream_error_event_produces_error() { let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); - let out = ep.collect_conversation_events(&event( + let out = ep.collect_thread_events(&event( "e1", EventMsg::StreamError(codex_core::protocol::StreamErrorEvent { message: "retrying".to_string(), @@ -328,12 +436,45 @@ fn stream_error_event_produces_error() { )); assert_eq!( out, - vec![ConversationEvent::Error(ConversationErrorEvent { + vec![ThreadEvent::Error(ThreadErrorEvent { message: "retrying".to_string(), })] ); } +#[test] +fn error_followed_by_task_complete_produces_turn_failed() { + let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + + let error_event = event( + "e1", + EventMsg::Error(ErrorEvent { + message: "boom".to_string(), + }), + ); + assert_eq!( + ep.collect_thread_events(&error_event), + vec![ThreadEvent::Error(ThreadErrorEvent { + message: "boom".to_string(), + })] + ); + + let complete_event = event( + "e2", + EventMsg::TaskComplete(codex_core::protocol::TaskCompleteEvent { + last_agent_message: None, + }), + ); + assert_eq!( + ep.collect_thread_events(&complete_event), + vec![ThreadEvent::TurnFailed(TurnFailedEvent { + error: ThreadErrorEvent { + message: "boom".to_string(), + }, + })] + ); +} + #[test] fn exec_command_end_success_produces_completed_command_item() { let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); @@ -348,13 +489,13 @@ fn exec_command_end_success_produces_completed_command_item() { parsed_cmd: Vec::new(), }), ); - let out_begin = ep.collect_conversation_events(&begin); + let out_begin = ep.collect_thread_events(&begin); assert_eq!( out_begin, - vec![ConversationEvent::ItemStarted(ItemStartedEvent { - item: ConversationItem { + vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ThreadItem { id: "item_0".to_string(), - details: ConversationItemDetails::CommandExecution(CommandExecutionItem { + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { command: "bash -lc 'echo hi'".to_string(), aggregated_output: String::new(), exit_code: None, @@ -377,13 +518,13 @@ fn exec_command_end_success_produces_completed_command_item() { formatted_output: String::new(), }), ); - let out_ok = ep.collect_conversation_events(&end_ok); + let out_ok = ep.collect_thread_events(&end_ok); assert_eq!( out_ok, - vec![ConversationEvent::ItemCompleted(ItemCompletedEvent { - item: ConversationItem { + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { id: "item_0".to_string(), - details: ConversationItemDetails::CommandExecution(CommandExecutionItem { + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { command: "bash -lc 'echo hi'".to_string(), aggregated_output: "hi\n".to_string(), exit_code: Some(0), @@ -409,11 +550,11 @@ fn exec_command_end_failure_produces_failed_command_item() { }), ); assert_eq!( - ep.collect_conversation_events(&begin), - vec![ConversationEvent::ItemStarted(ItemStartedEvent { - item: ConversationItem { + ep.collect_thread_events(&begin), + vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ThreadItem { id: "item_0".to_string(), - details: ConversationItemDetails::CommandExecution(CommandExecutionItem { + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { command: "sh -c 'exit 1'".to_string(), aggregated_output: String::new(), exit_code: None, @@ -436,13 +577,13 @@ fn exec_command_end_failure_produces_failed_command_item() { formatted_output: String::new(), }), ); - let out_fail = ep.collect_conversation_events(&end_fail); + let out_fail = ep.collect_thread_events(&end_fail); assert_eq!( out_fail, - vec![ConversationEvent::ItemCompleted(ItemCompletedEvent { - item: ConversationItem { + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { id: "item_0".to_string(), - details: ConversationItemDetails::CommandExecution(CommandExecutionItem { + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { command: "sh -c 'exit 1'".to_string(), aggregated_output: String::new(), exit_code: Some(1), @@ -457,7 +598,7 @@ fn exec_command_end_failure_produces_failed_command_item() { fn exec_command_end_without_begin_is_ignored() { let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); - // End event arrives without a prior Begin; should produce no conversation events. + // End event arrives without a prior Begin; should produce no thread events. let end_only = event( "c1", EventMsg::ExecCommandEnd(ExecCommandEndEvent { @@ -470,7 +611,7 @@ fn exec_command_end_without_begin_is_ignored() { formatted_output: String::new(), }), ); - let out = ep.collect_conversation_events(&end_only); + let out = ep.collect_thread_events(&end_only); assert!(out.is_empty()); } @@ -509,7 +650,7 @@ fn patch_apply_success_produces_item_completed_patchapply() { changes: changes.clone(), }), ); - let out_begin = ep.collect_conversation_events(&begin); + let out_begin = ep.collect_thread_events(&begin); assert!(out_begin.is_empty()); // End (success) -> item.completed (item_0) @@ -522,15 +663,15 @@ fn patch_apply_success_produces_item_completed_patchapply() { success: true, }), ); - let out_end = ep.collect_conversation_events(&end); + let out_end = ep.collect_thread_events(&end); assert_eq!(out_end.len(), 1); // Validate structure without relying on HashMap iteration order match &out_end[0] { - ConversationEvent::ItemCompleted(ItemCompletedEvent { item }) => { + ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) => { assert_eq!(&item.id, "item_0"); match &item.details { - ConversationItemDetails::FileChange(file_update) => { + ThreadItemDetails::FileChange(file_update) => { assert_eq!(file_update.status, PatchApplyStatus::Completed); let mut actual: Vec<(String, PatchChangeKind)> = file_update @@ -578,7 +719,7 @@ fn patch_apply_failure_produces_item_completed_patchapply_failed() { changes: changes.clone(), }), ); - assert!(ep.collect_conversation_events(&begin).is_empty()); + assert!(ep.collect_thread_events(&begin).is_empty()); // End (failure) -> item.completed (item_0) with Failed status let end = event( @@ -590,14 +731,14 @@ fn patch_apply_failure_produces_item_completed_patchapply_failed() { success: false, }), ); - let out_end = ep.collect_conversation_events(&end); + let out_end = ep.collect_thread_events(&end); assert_eq!(out_end.len(), 1); match &out_end[0] { - ConversationEvent::ItemCompleted(ItemCompletedEvent { item }) => { + ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) => { assert_eq!(&item.id, "item_0"); match &item.details { - ConversationItemDetails::FileChange(file_update) => { + ThreadItemDetails::FileChange(file_update) => { assert_eq!(file_update.status, PatchApplyStatus::Failed); assert_eq!(file_update.changes.len(), 1); assert_eq!(file_update.changes[0].path, "file.txt".to_string()); @@ -634,10 +775,7 @@ fn task_complete_produces_turn_completed_with_usage() { rate_limits: None, }), ); - assert!( - ep.collect_conversation_events(&token_count_event) - .is_empty() - ); + assert!(ep.collect_thread_events(&token_count_event).is_empty()); // Then TaskComplete should produce turn.completed with the captured usage. let complete_event = event( @@ -646,10 +784,10 @@ fn task_complete_produces_turn_completed_with_usage() { last_agent_message: Some("done".to_string()), }), ); - let out = ep.collect_conversation_events(&complete_event); + let out = ep.collect_thread_events(&complete_event); assert_eq!( out, - vec![ConversationEvent::TurnCompleted(TurnCompletedEvent { + vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage { input_tokens: 1200, cached_input_tokens: 200, diff --git a/codex-rs/exec/tests/suite/output_schema.rs b/codex-rs/exec/tests/suite/output_schema.rs index 03a04d0588..d9fcb763e7 100644 --- a/codex-rs/exec/tests/suite/output_schema.rs +++ b/codex-rs/exec/tests/suite/output_schema.rs @@ -31,7 +31,7 @@ async fn exec_includes_output_schema_in_request() -> anyhow::Result<()> { responses::ev_assistant_message("m1", "fixture hello"), responses::ev_completed("resp1"), ]); - responses::mount_sse_once(&server, any(), body).await; + responses::mount_sse_once_match(&server, any(), body).await; test.cmd_with_server(&server) .arg("--skip-git-repo-check") diff --git a/codex-rs/exec/tests/suite/server_error_exit.rs b/codex-rs/exec/tests/suite/server_error_exit.rs index 6b6358f0f4..051306cedd 100644 --- a/codex-rs/exec/tests/suite/server_error_exit.rs +++ b/codex-rs/exec/tests/suite/server_error_exit.rs @@ -21,7 +21,7 @@ async fn exits_non_zero_when_server_reports_error() -> anyhow::Result<()> { "error": {"code": "rate_limit_exceeded", "message": "synthetic server error"} } })]); - responses::mount_sse_once(&server, any(), body).await; + responses::mount_sse_once_match(&server, any(), body).await; test.cmd_with_server(&server) .arg("--skip-git-repo-check") diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index 3238c99f24..82b8fcb695 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -287,10 +287,27 @@ pub fn run( /// Sort matches in-place by descending score, then ascending path. fn sort_matches(matches: &mut [(u32, String)]) { - matches.sort_by(|a, b| match b.0.cmp(&a.0) { - std::cmp::Ordering::Equal => a.1.cmp(&b.1), + matches.sort_by(cmp_by_score_desc_then_path_asc::<(u32, String), _, _>( + |t| t.0, + |t| t.1.as_str(), + )); +} + +/// Returns a comparator closure suitable for `slice.sort_by(...)` that orders +/// items by descending score and then ascending path using the provided accessors. +pub fn cmp_by_score_desc_then_path_asc( + score_of: FScore, + path_of: FPath, +) -> impl FnMut(&T, &T) -> std::cmp::Ordering +where + FScore: Fn(&T) -> u32, + FPath: Fn(&T) -> &str, +{ + use std::cmp::Ordering; + move |a, b| match score_of(b).cmp(&score_of(a)) { + Ordering::Equal => path_of(a).cmp(path_of(b)), other => other, - }); + } } /// Maintains the `max_count` best matches for a given pattern. diff --git a/codex-rs/git-apply/Cargo.toml b/codex-rs/git-apply/Cargo.toml new file mode 100644 index 0000000000..0c17ccc3a4 --- /dev/null +++ b/codex-rs/git-apply/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "codex-git-apply" +version = { workspace = true } +edition = "2024" + +[lib] +name = "codex_git_apply" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +once_cell = "1" +regex = "1" +tempfile = "3" + diff --git a/codex-rs/git-apply/src/lib.rs b/codex-rs/git-apply/src/lib.rs new file mode 100644 index 0000000000..5d146312d9 --- /dev/null +++ b/codex-rs/git-apply/src/lib.rs @@ -0,0 +1,698 @@ +use once_cell::sync::Lazy; +use regex::Regex; +use std::ffi::OsStr; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct ApplyGitRequest { + pub cwd: PathBuf, + pub diff: String, + pub revert: bool, + pub preflight: bool, +} + +#[derive(Debug, Clone)] +pub struct ApplyGitResult { + pub exit_code: i32, + pub applied_paths: Vec, + pub skipped_paths: Vec, + pub conflicted_paths: Vec, + pub stdout: String, + pub stderr: String, + pub cmd_for_log: String, +} + +pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result { + let git_root = resolve_git_root(&req.cwd)?; + + // Write unified diff into a temporary file + let (tmpdir, patch_path) = write_temp_patch(&req.diff)?; + // Keep tmpdir alive until function end to ensure the file exists + let _guard = tmpdir; + + if req.revert && !req.preflight { + // Stage WT paths first to avoid index mismatch on revert. + stage_paths(&git_root, &req.diff)?; + } + + // Build git args + let mut args: Vec = vec!["apply".into(), "--3way".into()]; + if req.revert { + args.push("-R".into()); + } + + // Optional: additional git config via env knob (defaults OFF) + let mut cfg_parts: Vec = Vec::new(); + if let Ok(cfg) = std::env::var("CODEX_APPLY_GIT_CFG") { + for pair in cfg.split(',') { + let p = pair.trim(); + if p.is_empty() || !p.contains('=') { + continue; + } + cfg_parts.push("-c".into()); + cfg_parts.push(p.to_string()); + } + } + + args.push(patch_path.to_string_lossy().to_string()); + + // Optional preflight: dry-run only; do not modify working tree + if req.preflight { + let mut check_args = vec!["apply".to_string(), "--check".to_string()]; + if req.revert { + check_args.push("-R".to_string()); + } + check_args.push(patch_path.to_string_lossy().to_string()); + let rendered = render_command_for_log(&git_root, &cfg_parts, &check_args); + let (c_code, c_out, c_err) = run_git(&git_root, &cfg_parts, &check_args)?; + let (mut applied_paths, mut skipped_paths, mut conflicted_paths) = + parse_git_apply_output(&c_out, &c_err); + applied_paths.sort(); + applied_paths.dedup(); + skipped_paths.sort(); + skipped_paths.dedup(); + conflicted_paths.sort(); + conflicted_paths.dedup(); + return Ok(ApplyGitResult { + exit_code: c_code, + applied_paths, + skipped_paths, + conflicted_paths, + stdout: c_out, + stderr: c_err, + cmd_for_log: rendered, + }); + } + + let cmd_for_log = render_command_for_log(&git_root, &cfg_parts, &args); + let (code, stdout, stderr) = run_git(&git_root, &cfg_parts, &args)?; + + let (mut applied_paths, mut skipped_paths, mut conflicted_paths) = + parse_git_apply_output(&stdout, &stderr); + applied_paths.sort(); + applied_paths.dedup(); + skipped_paths.sort(); + skipped_paths.dedup(); + conflicted_paths.sort(); + conflicted_paths.dedup(); + + Ok(ApplyGitResult { + exit_code: code, + applied_paths, + skipped_paths, + conflicted_paths, + stdout, + stderr, + cmd_for_log, + }) +} + +fn resolve_git_root(cwd: &Path) -> io::Result { + let out = std::process::Command::new("git") + .arg("rev-parse") + .arg("--show-toplevel") + .current_dir(cwd) + .output()?; + let code = out.status.code().unwrap_or(-1); + if code != 0 { + return Err(io::Error::other(format!( + "not a git repository (exit {}): {}", + code, + String::from_utf8_lossy(&out.stderr) + ))); + } + let root = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Ok(PathBuf::from(root)) +} + +fn write_temp_patch(diff: &str) -> io::Result<(tempfile::TempDir, PathBuf)> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("patch.diff"); + std::fs::write(&path, diff)?; + Ok((dir, path)) +} + +fn run_git(cwd: &Path, git_cfg: &[String], args: &[String]) -> io::Result<(i32, String, String)> { + let mut cmd = std::process::Command::new("git"); + for p in git_cfg { + cmd.arg(p); + } + for a in args { + cmd.arg(a); + } + let out = cmd.current_dir(cwd).output()?; + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + Ok((code, stdout, stderr)) +} + +fn quote_shell(s: &str) -> String { + let simple = s + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.:/@%+".contains(c)); + if simple { + s.to_string() + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } +} + +fn render_command_for_log(cwd: &Path, git_cfg: &[String], args: &[String]) -> String { + let mut parts: Vec = Vec::new(); + parts.push("git".to_string()); + for a in git_cfg { + parts.push(quote_shell(a)); + } + for a in args { + parts.push(quote_shell(a)); + } + format!( + "(cd {} && {})", + quote_shell(&cwd.display().to_string()), + parts.join(" ") + ) +} + +pub fn extract_paths_from_patch(diff_text: &str) -> Vec { + static RE: Lazy = Lazy::new(|| { + Regex::new(r"(?m)^diff --git a/(.*?) b/(.*)$") + .unwrap_or_else(|e| panic!("invalid regex: {e}")) + }); + let mut set = std::collections::BTreeSet::new(); + for caps in RE.captures_iter(diff_text) { + if let Some(a) = caps.get(1).map(|m| m.as_str()) + && a != "/dev/null" + && !a.trim().is_empty() + { + set.insert(a.to_string()); + } + if let Some(b) = caps.get(2).map(|m| m.as_str()) + && b != "/dev/null" + && !b.trim().is_empty() + { + set.insert(b.to_string()); + } + } + set.into_iter().collect() +} + +pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> { + let paths = extract_paths_from_patch(diff); + let mut existing: Vec = Vec::new(); + for p in paths { + let joined = git_root.join(&p); + if std::fs::symlink_metadata(&joined).is_ok() { + existing.push(p); + } + } + if existing.is_empty() { + return Ok(()); + } + let mut cmd = std::process::Command::new("git"); + cmd.arg("add"); + cmd.arg("--"); + for p in &existing { + cmd.arg(OsStr::new(p)); + } + let out = cmd.current_dir(git_root).output()?; + let _code = out.status.code().unwrap_or(-1); + // We do not hard fail staging; best-effort is OK. Return Ok even on non-zero. + Ok(()) +} + +// ============ Parser ported from VS Code (TS) ============ + +pub fn parse_git_apply_output( + stdout: &str, + stderr: &str, +) -> (Vec, Vec, Vec) { + let combined = [stdout, stderr] + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::>() + .join("\n"); + + let mut applied = std::collections::BTreeSet::new(); + let mut skipped = std::collections::BTreeSet::new(); + let mut conflicted = std::collections::BTreeSet::new(); + let mut last_seen_path: Option = None; + + fn add(set: &mut std::collections::BTreeSet, raw: &str) { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return; + } + let first = trimmed.chars().next().unwrap_or('\0'); + let last = trimmed.chars().last().unwrap_or('\0'); + let unquoted = if (first == '"' || first == '\'') && last == first && trimmed.len() >= 2 { + &trimmed[1..trimmed.len() - 1] + } else { + trimmed + }; + if !unquoted.is_empty() { + set.insert(unquoted.to_string()); + } + } + + static APPLIED_CLEAN: Lazy = + Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P.+?)\\s+cleanly\\.?$")); + static APPLIED_CONFLICTS: Lazy = + Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P.+?)\\s+with conflicts\\.?$")); + static APPLYING_WITH_REJECTS: Lazy = Lazy::new(|| { + regex_ci("^Applying patch\\s+(?P.+?)\\s+with\\s+\\d+\\s+rejects?\\.{0,3}$") + }); + static CHECKING_PATCH: Lazy = + Lazy::new(|| regex_ci("^Checking patch\\s+(?P.+?)\\.\\.\\.$")); + static UNMERGED_LINE: Lazy = Lazy::new(|| regex_ci("^U\\s+(?P.+)$")); + static PATCH_FAILED: Lazy = + Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P.+?)(?::\\d+)?(?:\\s|$)")); + static DOES_NOT_APPLY: Lazy = + Lazy::new(|| regex_ci("^error:\\s+(?P.+?):\\s+patch does not apply$")); + static THREE_WAY_START: Lazy = Lazy::new(|| { + regex_ci("^(?:Performing three-way merge|Falling back to three-way merge)\\.\\.\\.$") + }); + static THREE_WAY_FAILED: Lazy = + Lazy::new(|| regex_ci("^Failed to perform three-way merge\\.\\.\\.$")); + static FALLBACK_DIRECT: Lazy = + Lazy::new(|| regex_ci("^Falling back to direct application\\.\\.\\.$")); + static LACKS_BLOB: Lazy = Lazy::new(|| { + regex_ci( + "^(?:error: )?repository lacks the necessary blob to (?:perform|fall back on) 3-?way merge\\.?$", + ) + }); + static INDEX_MISMATCH: Lazy = + Lazy::new(|| regex_ci("^error:\\s+(?P.+?):\\s+does not match index\\b")); + static NOT_IN_INDEX: Lazy = + Lazy::new(|| regex_ci("^error:\\s+(?P.+?):\\s+does not exist in index\\b")); + static ALREADY_EXISTS_WT: Lazy = Lazy::new(|| { + regex_ci("^error:\\s+(?P.+?)\\s+already exists in (?:the )?working directory\\b") + }); + static FILE_EXISTS: Lazy = + Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P.+?)\\s+File exists")); + static RENAMED_DELETED: Lazy = + Lazy::new(|| regex_ci("^error:\\s+path\\s+(?P.+?)\\s+has been renamed\\/deleted")); + static CANNOT_APPLY_BINARY: Lazy = Lazy::new(|| { + regex_ci( + "^error:\\s+cannot apply binary patch to\\s+['\\\"]?(?P.+?)['\\\"]?\\s+without full index line$", + ) + }); + static BINARY_DOES_NOT_APPLY: Lazy = Lazy::new(|| { + regex_ci("^error:\\s+binary patch does not apply to\\s+['\\\"]?(?P.+?)['\\\"]?$") + }); + static BINARY_INCORRECT_RESULT: Lazy = Lazy::new(|| { + regex_ci( + "^error:\\s+binary patch to\\s+['\\\"]?(?P.+?)['\\\"]?\\s+creates incorrect result\\b", + ) + }); + static CANNOT_READ_CURRENT: Lazy = Lazy::new(|| { + regex_ci("^error:\\s+cannot read the current contents of\\s+['\\\"]?(?P.+?)['\\\"]?$") + }); + static SKIPPED_PATCH: Lazy = + Lazy::new(|| regex_ci("^Skipped patch\\s+['\\\"]?(?P.+?)['\\\"]\\.$")); + static CANNOT_MERGE_BINARY_WARN: Lazy = Lazy::new(|| { + regex_ci( + "^warning:\\s*Cannot merge binary files:\\s+(?P.+?)\\s+\\(ours\\s+vs\\.\\s+theirs\\)", + ) + }); + + for raw_line in combined.lines() { + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + + // === "Checking patch ..." tracking === + if let Some(c) = CHECKING_PATCH.captures(line) { + if let Some(m) = c.name("path") { + last_seen_path = Some(m.as_str().to_string()); + } + continue; + } + + // === Status lines === + if let Some(c) = APPLIED_CLEAN.captures(line) { + if let Some(m) = c.name("path") { + add(&mut applied, m.as_str()); + let p = applied.iter().next_back().cloned(); + if let Some(p) = p { + conflicted.remove(&p); + skipped.remove(&p); + last_seen_path = Some(p); + } + } + continue; + } + if let Some(c) = APPLIED_CONFLICTS.captures(line) { + if let Some(m) = c.name("path") { + add(&mut conflicted, m.as_str()); + let p = conflicted.iter().next_back().cloned(); + if let Some(p) = p { + applied.remove(&p); + skipped.remove(&p); + last_seen_path = Some(p); + } + } + continue; + } + if let Some(c) = APPLYING_WITH_REJECTS.captures(line) { + if let Some(m) = c.name("path") { + add(&mut conflicted, m.as_str()); + let p = conflicted.iter().next_back().cloned(); + if let Some(p) = p { + applied.remove(&p); + skipped.remove(&p); + last_seen_path = Some(p); + } + } + continue; + } + + // === “U ” after conflicts === + if let Some(c) = UNMERGED_LINE.captures(line) { + if let Some(m) = c.name("path") { + add(&mut conflicted, m.as_str()); + let p = conflicted.iter().next_back().cloned(); + if let Some(p) = p { + applied.remove(&p); + skipped.remove(&p); + last_seen_path = Some(p); + } + } + continue; + } + + // === Early hints === + if PATCH_FAILED.is_match(line) || DOES_NOT_APPLY.is_match(line) { + if let Some(c) = PATCH_FAILED + .captures(line) + .or_else(|| DOES_NOT_APPLY.captures(line)) + && let Some(m) = c.name("path") + { + add(&mut skipped, m.as_str()); + last_seen_path = Some(m.as_str().to_string()); + } + continue; + } + + // === Ignore narration === + if THREE_WAY_START.is_match(line) || FALLBACK_DIRECT.is_match(line) { + continue; + } + + // === 3-way failed entirely; attribute to last_seen_path === + if THREE_WAY_FAILED.is_match(line) || LACKS_BLOB.is_match(line) { + if let Some(p) = last_seen_path.clone() { + add(&mut skipped, &p); + applied.remove(&p); + conflicted.remove(&p); + } + continue; + } + + // === Skips / I/O problems === + if let Some(c) = INDEX_MISMATCH + .captures(line) + .or_else(|| NOT_IN_INDEX.captures(line)) + .or_else(|| ALREADY_EXISTS_WT.captures(line)) + .or_else(|| FILE_EXISTS.captures(line)) + .or_else(|| RENAMED_DELETED.captures(line)) + .or_else(|| CANNOT_APPLY_BINARY.captures(line)) + .or_else(|| BINARY_DOES_NOT_APPLY.captures(line)) + .or_else(|| BINARY_INCORRECT_RESULT.captures(line)) + .or_else(|| CANNOT_READ_CURRENT.captures(line)) + .or_else(|| SKIPPED_PATCH.captures(line)) + { + if let Some(m) = c.name("path") { + add(&mut skipped, m.as_str()); + let p_now = skipped.iter().next_back().cloned(); + if let Some(p) = p_now { + applied.remove(&p); + conflicted.remove(&p); + last_seen_path = Some(p); + } + } + continue; + } + + // === Warnings that imply conflicts === + if let Some(c) = CANNOT_MERGE_BINARY_WARN.captures(line) { + if let Some(m) = c.name("path") { + add(&mut conflicted, m.as_str()); + let p = conflicted.iter().next_back().cloned(); + if let Some(p) = p { + applied.remove(&p); + skipped.remove(&p); + last_seen_path = Some(p); + } + } + continue; + } + } + + // Final precedence: conflicts > applied > skipped + for p in conflicted.iter() { + applied.remove(p); + skipped.remove(p); + } + for p in applied.iter() { + skipped.remove(p); + } + + ( + applied.into_iter().collect(), + skipped.into_iter().collect(), + conflicted.into_iter().collect(), + ) +} + +fn regex_ci(pat: &str) -> Regex { + Regex::new(&format!("(?i){pat}")).unwrap_or_else(|e| panic!("invalid regex: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + use std::sync::Mutex; + use std::sync::OnceLock; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let out = std::process::Command::new(args[0]) + .args(&args[1..]) + .current_dir(cwd) + .output() + .expect("spawn ok"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) + } + + fn init_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + // git init and minimal identity + let _ = run(root, &["git", "init"]); + let _ = run(root, &["git", "config", "user.email", "codex@example.com"]); + let _ = run(root, &["git", "config", "user.name", "Codex"]); + dir + } + + fn read_file_normalized(path: &Path) -> String { + std::fs::read_to_string(path) + .expect("read file") + .replace("\r\n", "\n") + } + + #[test] + fn apply_add_success() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + + let diff = "diff --git a/hello.txt b/hello.txt\nnew file mode 100644\n--- /dev/null\n+++ b/hello.txt\n@@ -0,0 +1,2 @@\n+hello\n+world\n"; + let req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let r = apply_git_patch(&req).expect("run apply"); + assert_eq!(r.exit_code, 0, "exit code 0"); + // File exists now + assert!(root.join("hello.txt").exists()); + } + + #[test] + fn apply_modify_conflict() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + // seed file and commit + std::fs::write(root.join("file.txt"), "line1\nline2\nline3\n").unwrap(); + let _ = run(root, &["git", "add", "file.txt"]); + let _ = run(root, &["git", "commit", "-m", "seed"]); + // local edit (unstaged) + std::fs::write(root.join("file.txt"), "line1\nlocal2\nline3\n").unwrap(); + // patch wants to change the same line differently + let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+remote2\n line3\n"; + let req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let r = apply_git_patch(&req).expect("run apply"); + assert_ne!(r.exit_code, 0, "non-zero exit on conflict"); + } + + #[test] + fn apply_modify_skipped_missing_index() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + // Try to modify a file that is not in the index + let diff = "diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n"; + let req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let r = apply_git_patch(&req).expect("run apply"); + assert_ne!(r.exit_code, 0, "non-zero exit on missing index"); + } + + #[test] + fn apply_then_revert_success() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + // Seed file and commit original content + std::fs::write(root.join("file.txt"), "orig\n").unwrap(); + let _ = run(root, &["git", "add", "file.txt"]); + let _ = run(root, &["git", "commit", "-m", "seed"]); + + // Forward patch: orig -> ORIG + let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n"; + let apply_req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let res_apply = apply_git_patch(&apply_req).expect("apply ok"); + assert_eq!(res_apply.exit_code, 0, "forward apply succeeded"); + let after_apply = read_file_normalized(&root.join("file.txt")); + assert_eq!(after_apply, "ORIG\n"); + + // Revert patch: ORIG -> orig (stage paths first; engine handles it) + let revert_req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: true, + preflight: false, + }; + let res_revert = apply_git_patch(&revert_req).expect("revert ok"); + assert_eq!(res_revert.exit_code, 0, "revert apply succeeded"); + let after_revert = read_file_normalized(&root.join("file.txt")); + assert_eq!(after_revert, "orig\n"); + } + + #[test] + fn revert_preflight_does_not_stage_index() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + // Seed repo and apply forward patch so the working tree reflects the change. + std::fs::write(root.join("file.txt"), "orig\n").unwrap(); + let _ = run(root, &["git", "add", "file.txt"]); + let _ = run(root, &["git", "commit", "-m", "seed"]); + + let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n"; + let apply_req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let res_apply = apply_git_patch(&apply_req).expect("apply ok"); + assert_eq!(res_apply.exit_code, 0, "forward apply succeeded"); + let (commit_code, _, commit_err) = run(root, &["git", "commit", "-am", "apply change"]); + assert_eq!(commit_code, 0, "commit applied change: {commit_err}"); + + let (_code_before, staged_before, _stderr_before) = + run(root, &["git", "diff", "--cached", "--name-only"]); + + let preflight_req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: true, + preflight: true, + }; + let res_preflight = apply_git_patch(&preflight_req).expect("preflight ok"); + assert_eq!(res_preflight.exit_code, 0, "revert preflight succeeded"); + let (_code_after, staged_after, _stderr_after) = + run(root, &["git", "diff", "--cached", "--name-only"]); + assert_eq!( + staged_after.trim(), + staged_before.trim(), + "preflight should not stage new paths", + ); + + let after_preflight = read_file_normalized(&root.join("file.txt")); + assert_eq!(after_preflight, "ORIG\n"); + } + + #[test] + fn preflight_blocks_partial_changes() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + // Build a multi-file diff: one valid add (ok.txt) and one invalid modify (ghost.txt) + let diff = "diff --git a/ok.txt b/ok.txt\nnew file mode 100644\n--- /dev/null\n+++ b/ok.txt\n@@ -0,0 +1,2 @@\n+alpha\n+beta\n\n\ +diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n"; + + // 1) With preflight enabled, nothing should be changed (even though ok.txt could be added) + let req1 = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: true, + }; + let r1 = apply_git_patch(&req1).expect("preflight apply"); + assert_ne!(r1.exit_code, 0, "preflight reports failure"); + assert!( + !root.join("ok.txt").exists(), + "preflight must prevent adding ok.txt" + ); + assert!( + r1.cmd_for_log.contains("--check"), + "preflight path recorded --check" + ); + + // 2) Without preflight, we should see no --check in the executed command + let req2 = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let r2 = apply_git_patch(&req2).expect("direct apply"); + assert_ne!(r2.exit_code, 0, "apply is expected to fail overall"); + assert!( + !r2.cmd_for_log.contains("--check"), + "non-preflight path should not use --check" + ); + } +} diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 5d358361c1..e2a693e9ac 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -33,3 +33,4 @@ webbrowser = { workspace = true } anyhow = { workspace = true } core_test_support = { workspace = true } tempfile = { workspace = true } +wiremock = { workspace = true } diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs new file mode 100644 index 0000000000..0d04a61337 --- /dev/null +++ b/codex-rs/login/src/device_code_auth.rs @@ -0,0 +1,196 @@ +use reqwest::StatusCode; +use serde::Deserialize; +use serde::Serialize; +use serde::de::Deserializer; +use serde::de::{self}; +use std::time::Duration; +use std::time::Instant; + +use crate::pkce::PkceCodes; +use crate::server::ServerOptions; +use std::io::Write; +use std::io::{self}; + +#[derive(Deserialize)] +struct UserCodeResp { + device_auth_id: String, + #[serde(alias = "user_code", alias = "usercode")] + user_code: String, + #[serde(default, deserialize_with = "deserialize_interval")] + interval: u64, +} + +#[derive(Serialize)] +struct UserCodeReq { + client_id: String, +} + +#[derive(Serialize)] +struct TokenPollReq { + device_auth_id: String, + user_code: String, +} + +fn deserialize_interval<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + s.trim() + .parse::() + .map_err(|e| de::Error::custom(format!("invalid u64 string: {e}"))) +} + +#[derive(Deserialize)] +struct CodeSuccessResp { + authorization_code: String, + code_challenge: String, + code_verifier: String, +} + +/// Request the user code and polling interval. +async fn request_user_code( + client: &reqwest::Client, + auth_base_url: &str, + client_id: &str, +) -> std::io::Result { + let url = format!("{auth_base_url}/deviceauth/usercode"); + let body = serde_json::to_string(&UserCodeReq { + client_id: client_id.to_string(), + }) + .map_err(std::io::Error::other)?; + let resp = client + .post(url) + .header("Content-Type", "application/json") + .body(body) + .send() + .await + .map_err(std::io::Error::other)?; + + if !resp.status().is_success() { + return Err(std::io::Error::other(format!( + "device code request failed with status {}", + resp.status() + ))); + } + + let body = resp.text().await.map_err(std::io::Error::other)?; + serde_json::from_str(&body).map_err(std::io::Error::other) +} + +/// Poll token endpoint until a code is issued or timeout occurs. +async fn poll_for_token( + client: &reqwest::Client, + auth_base_url: &str, + device_auth_id: &str, + user_code: &str, + interval: u64, +) -> std::io::Result { + let url = format!("{auth_base_url}/deviceauth/token"); + let max_wait = Duration::from_secs(15 * 60); + let start = Instant::now(); + + loop { + let body = serde_json::to_string(&TokenPollReq { + device_auth_id: device_auth_id.to_string(), + user_code: user_code.to_string(), + }) + .map_err(std::io::Error::other)?; + let resp = client + .post(&url) + .header("Content-Type", "application/json") + .body(body) + .send() + .await + .map_err(std::io::Error::other)?; + + let status = resp.status(); + + if status.is_success() { + return resp.json().await.map_err(std::io::Error::other); + } + + if status == StatusCode::FORBIDDEN || status == StatusCode::NOT_FOUND { + if start.elapsed() >= max_wait { + return Err(std::io::Error::other( + "device auth timed out after 15 minutes", + )); + } + let sleep_for = Duration::from_secs(interval).min(max_wait - start.elapsed()); + tokio::time::sleep(sleep_for).await; + continue; + } + + return Err(std::io::Error::other(format!( + "device auth failed with status {}", + resp.status() + ))); + } +} + +// Helper to print colored text if terminal supports ANSI +fn print_colored_warning_device_code() { + // ANSI escape code for bright yellow + const YELLOW: &str = "\x1b[93m"; + const RESET: &str = "\x1b[0m"; + let warning = "WARN!!! device code authentication has potential risks and\n\ + should be used with caution only in cases where browser support \n\ + is missing. This is prone to attacks.\n\ + \n\ + - This code is valid for 15 minutes.\n\ + - Do not share this code with anyone.\n\ + "; + let mut stdout = io::stdout().lock(); + let _ = write!(stdout, "{YELLOW}{warning}{RESET}"); + let _ = stdout.flush(); +} + +/// Full device code login flow. +pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { + let client = reqwest::Client::new(); + let auth_base_url = opts.issuer.trim_end_matches('/').to_owned(); + print_colored_warning_device_code(); + println!("⏳ Generating a new 9-digit device code for authentication...\n"); + let uc = request_user_code(&client, &auth_base_url, &opts.client_id).await?; + + println!( + "To authenticate, visit: {}/deviceauth/authorize and enter code: {}", + opts.issuer.trim_end_matches('/'), + uc.user_code + ); + + let code_resp = poll_for_token( + &client, + &auth_base_url, + &uc.device_auth_id, + &uc.user_code, + uc.interval, + ) + .await?; + + let pkce = PkceCodes { + code_verifier: code_resp.code_verifier, + code_challenge: code_resp.code_challenge, + }; + println!("authorization code received"); + let redirect_uri = format!("{}/deviceauth/callback", opts.issuer.trim_end_matches('/')); + + let tokens = crate::server::exchange_code_for_tokens( + &opts.issuer, + &opts.client_id, + &redirect_uri, + &pkce, + &code_resp.authorization_code, + ) + .await + .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?; + + crate::server::persist_tokens_async( + &opts.codex_home, + None, + tokens.id_token, + tokens.access_token, + tokens.refresh_token, + ) + .await +} diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index a737af22c8..d5e5836f0f 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -1,6 +1,8 @@ +mod device_code_auth; mod pkce; mod server; +pub use device_code_auth::run_device_code_login; pub use server::LoginServer; pub use server::ServerOptions; pub use server::ShutdownHandle; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index a1e5e6c3b0..275e98901f 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -16,7 +16,7 @@ use base64::Engine; use chrono::Utc; use codex_core::auth::AuthDotJson; use codex_core::auth::get_auth_file; -use codex_core::default_client::ORIGINATOR; +use codex_core::default_client::originator; use codex_core::token_data::TokenData; use codex_core::token_data::parse_id_token; use rand::RngCore; @@ -315,7 +315,7 @@ fn build_authorize_url( ("id_token_add_organizations", "true"), ("codex_cli_simplified_flow", "true"), ("state", state), - ("originator", ORIGINATOR.value.as_str()), + ("originator", originator().value.as_str()), ]; let qs = query .into_iter() @@ -393,13 +393,13 @@ fn bind_server(port: u16) -> io::Result { } } -struct ExchangedTokens { - id_token: String, - access_token: String, - refresh_token: String, +pub(crate) struct ExchangedTokens { + pub id_token: String, + pub access_token: String, + pub refresh_token: String, } -async fn exchange_code_for_tokens( +pub(crate) async fn exchange_code_for_tokens( issuer: &str, client_id: &str, redirect_uri: &str, @@ -443,7 +443,7 @@ async fn exchange_code_for_tokens( }) } -async fn persist_tokens_async( +pub(crate) async fn persist_tokens_async( codex_home: &Path, api_key: Option, id_token: String, @@ -562,7 +562,11 @@ fn jwt_auth_claims(jwt: &str) -> serde_json::Map { serde_json::Map::new() } -async fn obtain_api_key(issuer: &str, client_id: &str, id_token: &str) -> io::Result { +pub(crate) async fn obtain_api_key( + issuer: &str, + client_id: &str, + id_token: &str, +) -> io::Result { // Token exchange for an API key access token #[derive(serde::Deserialize)] struct ExchangeResp { diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs new file mode 100644 index 0000000000..0b63b98a6d --- /dev/null +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -0,0 +1,248 @@ +#![allow(clippy::unwrap_used)] + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use codex_core::auth::get_auth_file; +use codex_core::auth::try_read_auth_json; +use codex_login::ServerOptions; +use codex_login::run_device_code_login; +use serde_json::json; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use tempfile::tempdir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Request; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use core_test_support::skip_if_no_network; + +// ---------- Small helpers ---------- + +fn make_jwt(payload: serde_json::Value) -> String { + let header = json!({ "alg": "none", "typ": "JWT" }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") +} + +async fn mock_usercode_success(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/deviceauth/usercode")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "device_auth_id": "device-auth-123", + "user_code": "CODE-12345", + // NOTE: Interval is kept 0 in order to avoid waiting for the interval to pass + "interval": "0" + }))) + .mount(server) + .await; +} + +async fn mock_usercode_failure(server: &MockServer, status: u16) { + Mock::given(method("POST")) + .and(path("/deviceauth/usercode")) + .respond_with(ResponseTemplate::new(status)) + .mount(server) + .await; +} + +async fn mock_poll_token_two_step( + server: &MockServer, + counter: Arc, + first_response_status: u16, +) { + let c = counter.clone(); + Mock::given(method("POST")) + .and(path("/deviceauth/token")) + .respond_with(move |_: &Request| { + let attempt = c.fetch_add(1, Ordering::SeqCst); + if attempt == 0 { + ResponseTemplate::new(first_response_status) + } else { + ResponseTemplate::new(200).set_body_json(json!({ + "authorization_code": "poll-code-321", + "code_challenge": "code-challenge-321", + "code_verifier": "code-verifier-321" + })) + } + }) + .expect(2) + .mount(server) + .await; +} + +async fn mock_poll_token_single(server: &MockServer, endpoint: &str, response: ResponseTemplate) { + Mock::given(method("POST")) + .and(path(endpoint)) + .respond_with(response) + .mount(server) + .await; +} + +async fn mock_oauth_token_single(server: &MockServer, jwt: String) { + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id_token": jwt.clone(), + "access_token": "access-token-123", + "refresh_token": "refresh-token-123" + }))) + .mount(server) + .await; +} + +fn server_opts(codex_home: &tempfile::TempDir, issuer: String) -> ServerOptions { + let mut opts = ServerOptions::new(codex_home.path().to_path_buf(), "client-id".to_string()); + opts.issuer = issuer; + opts.open_browser = false; + opts +} + +#[tokio::test] +async fn device_code_login_integration_succeeds() { + skip_if_no_network!(); + + let codex_home = tempdir().unwrap(); + let mock_server = MockServer::start().await; + + mock_usercode_success(&mock_server).await; + + mock_poll_token_two_step(&mock_server, Arc::new(AtomicUsize::new(0)), 404).await; + + let jwt = make_jwt(json!({ + "https://api.openai.com/auth": { + "chatgpt_account_id": "acct_321" + } + })); + + mock_oauth_token_single(&mock_server, jwt.clone()).await; + + let issuer = mock_server.uri(); + let opts = server_opts(&codex_home, issuer); + + run_device_code_login(opts) + .await + .expect("device code login integration should succeed"); + + let auth_path = get_auth_file(codex_home.path()); + let auth = try_read_auth_json(&auth_path).expect("auth.json written"); + // assert_eq!(auth.openai_api_key.as_deref(), Some("api-key-321")); + let tokens = auth.tokens.expect("tokens persisted"); + assert_eq!(tokens.access_token, "access-token-123"); + assert_eq!(tokens.refresh_token, "refresh-token-123"); + assert_eq!(tokens.id_token.raw_jwt, jwt); + assert_eq!(tokens.account_id.as_deref(), Some("acct_321")); +} + +#[tokio::test] +async fn device_code_login_integration_handles_usercode_http_failure() { + skip_if_no_network!(); + + let codex_home = tempdir().unwrap(); + let mock_server = MockServer::start().await; + + mock_usercode_failure(&mock_server, 503).await; + + let issuer = mock_server.uri(); + + let opts = server_opts(&codex_home, issuer); + + let err = run_device_code_login(opts) + .await + .expect_err("usercode HTTP failure should bubble up"); + assert!( + err.to_string() + .contains("device code request failed with status"), + "unexpected error: {err:?}" + ); + + let auth_path = get_auth_file(codex_home.path()); + assert!(!auth_path.exists()); +} + +#[tokio::test] +async fn device_code_login_integration_persists_without_api_key_on_exchange_failure() { + skip_if_no_network!(); + + let codex_home = tempdir().unwrap(); + + let mock_server = MockServer::start().await; + + mock_usercode_success(&mock_server).await; + + mock_poll_token_two_step(&mock_server, Arc::new(AtomicUsize::new(0)), 404).await; + + let jwt = make_jwt(json!({})); + + mock_oauth_token_single(&mock_server, jwt.clone()).await; + + let issuer = mock_server.uri(); + + let mut opts = ServerOptions::new(codex_home.path().to_path_buf(), "client-id".to_string()); + opts.issuer = issuer; + opts.open_browser = false; + + run_device_code_login(opts) + .await + .expect("device login should succeed without API key exchange"); + + let auth_path = get_auth_file(codex_home.path()); + let auth = try_read_auth_json(&auth_path).expect("auth.json written"); + assert!(auth.openai_api_key.is_none()); + let tokens = auth.tokens.expect("tokens persisted"); + assert_eq!(tokens.access_token, "access-token-123"); + assert_eq!(tokens.refresh_token, "refresh-token-123"); + assert_eq!(tokens.id_token.raw_jwt, jwt); +} + +#[tokio::test] +async fn device_code_login_integration_handles_error_payload() { + skip_if_no_network!(); + + let codex_home = tempdir().unwrap(); + + // Start WireMock + let mock_server = MockServer::start().await; + + mock_usercode_success(&mock_server).await; + + // // /deviceauth/token → returns error payload with status 401 + mock_poll_token_single( + &mock_server, + "/deviceauth/token", + ResponseTemplate::new(401).set_body_json(json!({ + "error": "authorization_declined", + "error_description": "Denied" + })), + ) + .await; + + // (WireMock will automatically 404 for other paths) + + let issuer = mock_server.uri(); + + let mut opts = ServerOptions::new(codex_home.path().to_path_buf(), "client-id".to_string()); + opts.issuer = issuer; + opts.open_browser = false; + + let err = run_device_code_login(opts) + .await + .expect_err("integration failure path should return error"); + + // Accept either the specific error payload, a 400, or a 404 (since the client may return 404 if the flow is incomplete) + assert!( + err.to_string().contains("authorization_declined") || err.to_string().contains("401"), + "Expected an authorization_declined / 400 / 404 error, got {err:?}" + ); + + let auth_path = get_auth_file(codex_home.path()); + assert!( + !auth_path.exists(), + "auth.json should not be created when device auth fails" + ); +} diff --git a/codex-rs/login/tests/suite/mod.rs b/codex-rs/login/tests/suite/mod.rs index 3259e72434..b84b264bec 100644 --- a/codex-rs/login/tests/suite/mod.rs +++ b/codex-rs/login/tests/suite/mod.rs @@ -1,2 +1,3 @@ // Aggregates all former standalone integration tests as modules. +mod device_code_login; mod login_server_e2e; diff --git a/codex-rs/mcp-client/src/mcp_client.rs b/codex-rs/mcp-client/src/mcp_client.rs index 087335e66b..27f96494ee 100644 --- a/codex-rs/mcp-client/src/mcp_client.rs +++ b/codex-rs/mcp-client/src/mcp_client.rs @@ -274,8 +274,7 @@ impl McpClient { err.error.code, err.error.message ))), other => Err(anyhow!(format!( - "unexpected message variant received in reply path: {:?}", - other + "unexpected message variant received in reply path: {other:?}" ))), } } diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index e40dd15df7..484af6d8e8 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -19,8 +19,8 @@ anyhow = { workspace = true } codex-arg0 = { workspace = true } codex-common = { workspace = true, features = ["cli"] } codex-core = { workspace = true } -codex-login = { workspace = true } codex-protocol = { workspace = true } +codex-utils-json-to-toml = { workspace = true } mcp-types = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -33,14 +33,11 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } -toml = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } -uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] assert_cmd = { workspace = true } -base64 = { workspace = true } core_test_support = { workspace = true } mcp_test_support = { workspace = true } os_info = { workspace = true } diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index d90924aa94..3ee1669020 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -2,6 +2,7 @@ use codex_core::protocol::AskForApproval; use codex_protocol::config_types::SandboxMode; +use codex_utils_json_to_toml::json_to_toml; use mcp_types::Tool; use mcp_types::ToolInputSchema; use schemars::JsonSchema; @@ -11,8 +12,6 @@ use serde::Serialize; use std::collections::HashMap; use std::path::PathBuf; -use crate::json_to_toml::json_to_toml; - /// Client-supplied configuration for a `codex` tool-call. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] #[serde(rename_all = "kebab-case")] diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index b1397846ca..ffc4b3e362 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -20,12 +20,10 @@ use tracing::error; use tracing::info; use tracing_subscriber::EnvFilter; -mod codex_message_processor; mod codex_tool_config; mod codex_tool_runner; mod error_code; mod exec_approval; -mod json_to_toml; pub(crate) mod message_processor; mod outgoing_message; mod patch_approval; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 5868d60fc0..addc9572ec 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -1,14 +1,12 @@ use std::collections::HashMap; use std::path::PathBuf; -use crate::codex_message_processor::CodexMessageProcessor; use crate::codex_tool_config::CodexToolCallParam; use crate::codex_tool_config::CodexToolCallReplyParam; use crate::codex_tool_config::create_tool_for_codex_tool_call_param; use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::outgoing_message::OutgoingMessageSender; -use codex_protocol::mcp_protocol::ClientRequest; use codex_protocol::mcp_protocol::ConversationId; use codex_core::AuthManager; @@ -38,7 +36,6 @@ use tokio::sync::Mutex; use tokio::task; pub(crate) struct MessageProcessor { - codex_message_processor: CodexMessageProcessor, outgoing: Arc, initialized: bool, codex_linux_sandbox_exe: Option, @@ -56,16 +53,8 @@ impl MessageProcessor { ) -> Self { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared(config.codex_home.clone()); - let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); - let codex_message_processor = CodexMessageProcessor::new( - auth_manager, - conversation_manager.clone(), - outgoing.clone(), - codex_linux_sandbox_exe.clone(), - config, - ); + let conversation_manager = Arc::new(ConversationManager::new(auth_manager)); Self { - codex_message_processor, outgoing, initialized: false, codex_linux_sandbox_exe, @@ -75,17 +64,6 @@ impl MessageProcessor { } pub(crate) async fn process_request(&mut self, request: JSONRPCRequest) { - if let Ok(request_json) = serde_json::to_value(request.clone()) - && let Ok(codex_request) = serde_json::from_value::(request_json) - { - // If the request is a Codex request, handle it with the Codex - // message processor. - self.codex_message_processor - .process_request(codex_request) - .await; - return; - } - // Hold on to the ID so we can respond. let request_id = request.id.clone(); diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 6f376b1df5..2e350dd9a9 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -3,7 +3,6 @@ use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; use codex_core::protocol::Event; -use codex_protocol::mcp_protocol::ServerNotification; use mcp_types::JSONRPC_VERSION; use mcp_types::JSONRPCError; use mcp_types::JSONRPCErrorError; @@ -125,12 +124,6 @@ impl OutgoingMessageSender { .await; } - pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { - let _ = self - .sender - .send(OutgoingMessage::AppServerNotification(notification)); - } - pub(crate) async fn send_notification(&self, notification: OutgoingNotification) { let outgoing_message = OutgoingMessage::Notification(notification); let _ = self.sender.send(outgoing_message); @@ -146,9 +139,6 @@ impl OutgoingMessageSender { pub(crate) enum OutgoingMessage { Request(OutgoingRequest), Notification(OutgoingNotification), - /// AppServerNotification is specific to the case where this is run as an - /// "app server" as opposed to an MCP server. - AppServerNotification(ServerNotification), Response(OutgoingResponse), Error(OutgoingError), } @@ -172,21 +162,6 @@ impl From for JSONRPCMessage { params, }) } - AppServerNotification(notification) => { - let method = notification.to_string(); - let params = match notification.to_params() { - Ok(params) => Some(params), - Err(err) => { - warn!("failed to serialize notification params: {err}"); - None - } - }; - JSONRPCMessage::Notification(JSONRPCNotification { - jsonrpc: JSONRPC_VERSION.into(), - method, - params, - }) - } Response(OutgoingResponse { id, result }) => { JSONRPCMessage::Response(JSONRPCResponse { jsonrpc: JSONRPC_VERSION.into(), @@ -261,11 +236,9 @@ mod tests { use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::mcp_protocol::ConversationId; - use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::NamedTempFile; - use uuid::Uuid; use super::*; @@ -357,29 +330,4 @@ mod tests { assert_eq!(params.unwrap(), expected_params); Ok(()) } - - #[test] - fn verify_server_notification_serialization() { - let notification = - ServerNotification::LoginChatGptComplete(LoginChatGptCompleteNotification { - login_id: Uuid::nil(), - success: true, - error: None, - }); - - let jsonrpc_notification: JSONRPCMessage = - OutgoingMessage::AppServerNotification(notification).into(); - assert_eq!( - JSONRPCMessage::Notification(JSONRPCNotification { - jsonrpc: "2.0".into(), - method: "loginChatGptComplete".into(), - params: Some(json!({ - "loginId": Uuid::nil(), - "success": true, - })), - }), - jsonrpc_notification, - "ensure the strum macros serialize the method field correctly" - ); - } } diff --git a/codex-rs/mcp-server/tests/common/Cargo.toml b/codex-rs/mcp-server/tests/common/Cargo.toml index e6f7117250..7c2bc2266a 100644 --- a/codex-rs/mcp-server/tests/common/Cargo.toml +++ b/codex-rs/mcp-server/tests/common/Cargo.toml @@ -11,7 +11,6 @@ anyhow = { workspace = true } assert_cmd = { workspace = true } codex-core = { workspace = true } codex-mcp-server = { workspace = true } -codex-protocol = { workspace = true } mcp-types = { workspace = true } os_info = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index a30e9817f2..a6bc966d7e 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -12,19 +12,6 @@ use tokio::process::ChildStdout; use anyhow::Context; use assert_cmd::prelude::*; use codex_mcp_server::CodexToolCallParam; -use codex_protocol::mcp_protocol::AddConversationListenerParams; -use codex_protocol::mcp_protocol::ArchiveConversationParams; -use codex_protocol::mcp_protocol::CancelLoginChatGptParams; -use codex_protocol::mcp_protocol::GetAuthStatusParams; -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::CallToolRequestParams; use mcp_types::ClientCapabilities; @@ -213,150 +200,6 @@ impl McpProcess { .await } - /// Send a `newConversation` JSON-RPC request. - pub async fn send_new_conversation_request( - &mut self, - params: NewConversationParams, - ) -> anyhow::Result { - 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 { - 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 { - 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 { - // 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 { - 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 { - 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 { - 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 { - 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 { - self.send_request("getUserSavedConfig", None).await - } - - /// Send a `getUserAgent` JSON-RPC request. - pub async fn send_get_user_agent_request(&mut self) -> anyhow::Result { - self.send_request("getUserAgent", None).await - } - - /// Send a `userInfo` JSON-RPC request. - pub async fn send_user_info_request(&mut self) -> anyhow::Result { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - self.send_request("logoutChatGpt", None).await - } - async fn send_request( &mut self, method: &str, @@ -454,58 +297,6 @@ impl McpProcess { } } - pub async fn read_stream_until_error_message( - &mut self, - request_id: RequestId, - ) -> anyhow::Result { - 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 { - 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:?}"); - } - } - } - } - /// Reads notifications until a legacy TaskComplete event is observed: /// Method "codex/event" with params.msg.type == "task_complete". pub async fn read_stream_until_legacy_task_complete_notification( diff --git a/codex-rs/mcp-server/tests/suite/mod.rs b/codex-rs/mcp-server/tests/suite/mod.rs index 97e5370908..6b50853b16 100644 --- a/codex-rs/mcp-server/tests/suite/mod.rs +++ b/codex-rs/mcp-server/tests/suite/mod.rs @@ -1,14 +1 @@ -// Aggregates all former standalone integration tests as modules. -mod archive_conversation; -mod auth; -mod codex_message_processor_flow; mod codex_tool; -mod config; -mod create_conversation; -mod interrupt; -mod list_resume; -mod login; -mod send_message; -mod set_default_model; -mod user_agent; -mod user_info; diff --git a/codex-rs/otel/Cargo.toml b/codex-rs/otel/Cargo.toml new file mode 100644 index 0000000000..610423f9cc --- /dev/null +++ b/codex-rs/otel/Cargo.toml @@ -0,0 +1,39 @@ +[package] +edition = "2024" +name = "codex-otel" +version = { workspace = true } + +[lib] +name = "codex_otel" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[features] +# Compile-time gate for OTLP support; disabled by default. +# Downstream crates can enable via `features = ["otel"]`. +default = [] +otel = [ + "opentelemetry", + "opentelemetry_sdk", + "opentelemetry-otlp", + "tonic", +] + +[dependencies] +codex-protocol = { path = "../protocol" } +chrono = { workspace = true } +tracing = { workspace = true } +opentelemetry = { workspace = true, features = ["logs"], optional = true } +opentelemetry_sdk = { workspace = true, features = ["logs", "rt-tokio"], optional = true } +opentelemetry-otlp = { workspace = true, features = ["grpc-tonic", "http-proto", "http-json", "reqwest", "reqwest-rustls"], optional = true } +opentelemetry-semantic-conventions = { workspace = true } +tonic = { workspace = true, optional = true } +serde = { workspace = true, features = ["derive"] } +strum_macros = { workspace = true } +reqwest = { workspace = true } +eventsource-stream = { workspace = true } +tokio = { workspace = true } +serde_json = { workspace = true } \ No newline at end of file diff --git a/codex-rs/otel/src/config.rs b/codex-rs/otel/src/config.rs new file mode 100644 index 0000000000..77063ed09d --- /dev/null +++ b/codex-rs/otel/src/config.rs @@ -0,0 +1,33 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct OtelSettings { + pub environment: String, + pub service_name: String, + pub service_version: String, + pub codex_home: PathBuf, + pub exporter: OtelExporter, +} + +#[derive(Clone, Debug)] +pub enum OtelHttpProtocol { + /// HTTP protocol with binary protobuf + Binary, + /// HTTP protocol with JSON payload + Json, +} + +#[derive(Clone, Debug)] +pub enum OtelExporter { + None, + OtlpGrpc { + endpoint: String, + headers: HashMap, + }, + OtlpHttp { + endpoint: String, + headers: HashMap, + protocol: OtelHttpProtocol, + }, +} diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs new file mode 100644 index 0000000000..3fdb6f7413 --- /dev/null +++ b/codex-rs/otel/src/lib.rs @@ -0,0 +1,26 @@ +pub mod config; + +pub mod otel_event_manager; +#[cfg(feature = "otel")] +pub mod otel_provider; + +#[cfg(not(feature = "otel"))] +mod imp { + use reqwest::header::HeaderMap; + use tracing::Span; + + pub struct OtelProvider; + + impl OtelProvider { + pub fn from(_settings: &crate::config::OtelSettings) -> Option { + None + } + + pub fn headers(_span: &Span) -> HeaderMap { + HeaderMap::new() + } + } +} + +#[cfg(not(feature = "otel"))] +pub use imp::OtelProvider; diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs new file mode 100644 index 0000000000..87ed6bb535 --- /dev/null +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -0,0 +1,459 @@ +use chrono::SecondsFormat; +use chrono::Utc; +use codex_protocol::config_types::ReasoningEffort; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::mcp_protocol::AuthMode; +use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::InputItem; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; +use eventsource_stream::Event as StreamEvent; +use eventsource_stream::EventStreamError as StreamError; +use reqwest::Error; +use reqwest::Response; +use serde::Serialize; +use std::fmt::Display; +use std::time::Duration; +use std::time::Instant; +use strum_macros::Display; +use tokio::time::error::Elapsed; + +#[derive(Debug, Clone, Serialize, Display)] +#[serde(rename_all = "snake_case")] +pub enum ToolDecisionSource { + Config, + User, +} + +#[derive(Debug, Clone)] +pub struct OtelEventMetadata { + conversation_id: ConversationId, + auth_mode: Option, + account_id: Option, + model: String, + slug: String, + log_user_prompts: bool, + app_version: &'static str, + terminal_type: String, +} + +#[derive(Debug, Clone)] +pub struct OtelEventManager { + metadata: OtelEventMetadata, +} + +impl OtelEventManager { + pub fn new( + conversation_id: ConversationId, + model: &str, + slug: &str, + account_id: Option, + auth_mode: Option, + log_user_prompts: bool, + terminal_type: String, + ) -> OtelEventManager { + Self { + metadata: OtelEventMetadata { + conversation_id, + auth_mode: auth_mode.map(|m| m.to_string()), + account_id, + model: model.to_owned(), + slug: slug.to_owned(), + log_user_prompts, + app_version: env!("CARGO_PKG_VERSION"), + terminal_type, + }, + } + } + + pub fn with_model(&self, model: &str, slug: &str) -> Self { + let mut manager = self.clone(); + manager.metadata.model = model.to_owned(); + manager.metadata.slug = slug.to_owned(); + manager + } + + #[allow(clippy::too_many_arguments)] + pub fn conversation_starts( + &self, + provider_name: &str, + reasoning_effort: Option, + reasoning_summary: ReasoningSummary, + context_window: Option, + max_output_tokens: Option, + auto_compact_token_limit: Option, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, + mcp_servers: Vec<&str>, + active_profile: Option, + ) { + tracing::event!( + tracing::Level::INFO, + event.name = "codex.conversation_starts", + event.timestamp = %timestamp(), + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + provider_name = %provider_name, + reasoning_effort = reasoning_effort.map(|e| e.to_string()), + reasoning_summary = %reasoning_summary, + context_window = context_window, + max_output_tokens = max_output_tokens, + auto_compact_token_limit = auto_compact_token_limit, + approval_policy = %approval_policy, + sandbox_policy = %sandbox_policy, + mcp_servers = mcp_servers.join(", "), + active_profile = active_profile, + ) + } + + pub async fn log_request(&self, attempt: u64, f: F) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let start = std::time::Instant::now(); + let response = f().await; + let duration = start.elapsed(); + + let (status, error) = match &response { + Ok(response) => (Some(response.status().as_u16()), None), + Err(error) => (error.status().map(|s| s.as_u16()), Some(error.to_string())), + }; + + tracing::event!( + tracing::Level::INFO, + event.name = "codex.api_request", + event.timestamp = %timestamp(), + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + duration_ms = %duration.as_millis(), + http.response.status_code = status, + error.message = error, + attempt = attempt, + ); + + response + } + + pub async fn log_sse_event( + &self, + next: Next, + ) -> Result>>, Elapsed> + where + Next: FnOnce() -> Fut, + Fut: Future>>, Elapsed>>, + E: Display, + { + let start = std::time::Instant::now(); + let response = next().await; + let duration = start.elapsed(); + + match response { + Ok(Some(Ok(ref sse))) => { + if sse.data.trim() == "[DONE]" { + self.sse_event(&sse.event, duration); + } else { + match serde_json::from_str::(&sse.data) { + Ok(error) if sse.event == "response.failed" => { + self.sse_event_failed(Some(&sse.event), duration, &error); + } + Ok(content) if sse.event == "response.output_item.done" => { + match serde_json::from_value::(content) { + Ok(_) => self.sse_event(&sse.event, duration), + Err(_) => { + self.sse_event_failed( + Some(&sse.event), + duration, + &"failed to parse response.output_item.done", + ); + } + }; + } + Ok(_) => { + self.sse_event(&sse.event, duration); + } + Err(error) => { + self.sse_event_failed(Some(&sse.event), duration, &error); + } + } + } + } + Ok(Some(Err(ref error))) => { + self.sse_event_failed(None, duration, error); + } + Ok(None) => {} + Err(_) => { + self.sse_event_failed(None, duration, &"idle timeout waiting for SSE"); + } + } + + response + } + + fn sse_event(&self, kind: &str, duration: Duration) { + tracing::event!( + tracing::Level::INFO, + event.name = "codex.sse_event", + event.timestamp = %timestamp(), + event.kind = %kind, + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + duration_ms = %duration.as_millis(), + ); + } + + pub fn sse_event_failed(&self, kind: Option<&String>, duration: Duration, error: &T) + where + T: Display, + { + match kind { + Some(kind) => tracing::event!( + tracing::Level::INFO, + event.name = "codex.sse_event", + event.timestamp = %timestamp(), + event.kind = %kind, + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + duration_ms = %duration.as_millis(), + error.message = %error, + ), + None => tracing::event!( + tracing::Level::INFO, + event.name = "codex.sse_event", + event.timestamp = %timestamp(), + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + duration_ms = %duration.as_millis(), + error.message = %error, + ), + } + } + + pub fn see_event_completed_failed(&self, error: &T) + where + T: Display, + { + tracing::event!( + tracing::Level::INFO, + event.name = "codex.sse_event", + event.kind = %"response.completed", + event.timestamp = %timestamp(), + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + error.message = %error, + ) + } + + pub fn sse_event_completed( + &self, + input_token_count: u64, + output_token_count: u64, + cached_token_count: Option, + reasoning_token_count: Option, + tool_token_count: u64, + ) { + tracing::event!( + tracing::Level::INFO, + event.name = "codex.sse_event", + event.timestamp = %timestamp(), + event.kind = %"response.completed", + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + input_token_count = %input_token_count, + output_token_count = %output_token_count, + cached_token_count = cached_token_count, + reasoning_token_count = reasoning_token_count, + tool_token_count = %tool_token_count, + ); + } + + pub fn user_prompt(&self, items: &[InputItem]) { + let prompt = items + .iter() + .flat_map(|item| match item { + InputItem::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::(); + + let prompt_to_log = if self.metadata.log_user_prompts { + prompt.as_str() + } else { + "[REDACTED]" + }; + + tracing::event!( + tracing::Level::INFO, + event.name = "codex.user_prompt", + event.timestamp = %timestamp(), + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + prompt_length = %prompt.chars().count(), + prompt = %prompt_to_log, + ); + } + + pub fn tool_decision( + &self, + tool_name: &str, + call_id: &str, + decision: ReviewDecision, + source: ToolDecisionSource, + ) { + tracing::event!( + tracing::Level::INFO, + event.name = "codex.tool_decision", + event.timestamp = %timestamp(), + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + tool_name = %tool_name, + call_id = %call_id, + decision = %decision.to_string().to_lowercase(), + source = %source.to_string(), + ); + } + + pub async fn log_tool_result( + &self, + tool_name: &str, + call_id: &str, + arguments: &str, + f: F, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + E: Display, + { + let start = Instant::now(); + let result = f().await; + let duration = start.elapsed(); + + let (output, success) = match &result { + Ok(content) => (content, true), + Err(error) => (&error.to_string(), false), + }; + + tracing::event!( + tracing::Level::INFO, + event.name = "codex.tool_result", + event.timestamp = %timestamp(), + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + tool_name = %tool_name, + call_id = %call_id, + arguments = %arguments, + duration_ms = %duration.as_millis(), + success = %success, + output = %output, + ); + + result + } + + pub fn log_tool_failed(&self, tool_name: &str, error: &str) { + tracing::event!( + tracing::Level::INFO, + event.name = "codex.tool_result", + event.timestamp = %timestamp(), + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + tool_name = %tool_name, + duration_ms = %Duration::ZERO.as_millis(), + success = %false, + output = %error, + ); + } + + pub fn tool_result( + &self, + tool_name: &str, + call_id: &str, + arguments: &str, + duration: Duration, + success: bool, + output: &str, + ) { + let success_str = if success { "true" } else { "false" }; + + tracing::event!( + tracing::Level::INFO, + event.name = "codex.tool_result", + event.timestamp = %timestamp(), + conversation.id = %self.metadata.conversation_id, + app.version = %self.metadata.app_version, + auth_mode = self.metadata.auth_mode, + user.account_id = self.metadata.account_id, + terminal.type = %self.metadata.terminal_type, + model = %self.metadata.model, + slug = %self.metadata.slug, + tool_name = %tool_name, + call_id = %call_id, + arguments = %arguments, + duration_ms = %duration.as_millis(), + success = %success_str, + output = %output, + ); + } +} + +fn timestamp() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} diff --git a/codex-rs/otel/src/otel_provider.rs b/codex-rs/otel/src/otel_provider.rs new file mode 100644 index 0000000000..222322a2ea --- /dev/null +++ b/codex-rs/otel/src/otel_provider.rs @@ -0,0 +1,103 @@ +use crate::config::OtelExporter; +use crate::config::OtelHttpProtocol; +use crate::config::OtelSettings; +use opentelemetry::KeyValue; +use opentelemetry_otlp::LogExporter; +use opentelemetry_otlp::Protocol; +use opentelemetry_otlp::WithExportConfig; +use opentelemetry_otlp::WithHttpConfig; +use opentelemetry_otlp::WithTonicConfig; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::logs::SdkLoggerProvider; +use opentelemetry_semantic_conventions as semconv; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; +use std::error::Error; +use tonic::metadata::MetadataMap; +use tracing::debug; + +const ENV_ATTRIBUTE: &str = "env"; + +pub struct OtelProvider { + pub logger: SdkLoggerProvider, +} + +impl OtelProvider { + pub fn shutdown(&self) { + let _ = self.logger.shutdown(); + } + + pub fn from(settings: &OtelSettings) -> Result, Box> { + let resource = Resource::builder() + .with_service_name(settings.service_name.clone()) + .with_attributes(vec![ + KeyValue::new( + semconv::attribute::SERVICE_VERSION, + settings.service_version.clone(), + ), + KeyValue::new(ENV_ATTRIBUTE, settings.environment.clone()), + ]) + .build(); + + let mut builder = SdkLoggerProvider::builder().with_resource(resource); + + match &settings.exporter { + OtelExporter::None => { + debug!("No exporter enabled in OTLP settings."); + return Ok(None); + } + OtelExporter::OtlpGrpc { endpoint, headers } => { + debug!("Using OTLP Grpc exporter: {}", endpoint); + + let mut header_map = HeaderMap::new(); + for (key, value) in headers { + if let Ok(name) = HeaderName::from_bytes(key.as_bytes()) + && let Ok(val) = HeaderValue::from_str(value) + { + header_map.insert(name, val); + } + } + + let exporter = LogExporter::builder() + .with_tonic() + .with_endpoint(endpoint) + .with_metadata(MetadataMap::from_headers(header_map)) + .build()?; + + builder = builder.with_batch_exporter(exporter); + } + OtelExporter::OtlpHttp { + endpoint, + headers, + protocol, + } => { + debug!("Using OTLP Http exporter: {}", endpoint); + + let protocol = match protocol { + OtelHttpProtocol::Binary => Protocol::HttpBinary, + OtelHttpProtocol::Json => Protocol::HttpJson, + }; + + let exporter = LogExporter::builder() + .with_http() + .with_endpoint(endpoint) + .with_protocol(protocol) + .with_headers(headers.clone()) + .build()?; + + builder = builder.with_batch_exporter(exporter); + } + } + + Ok(Some(Self { + logger: builder.build(), + })) + } +} + +impl Drop for OtelProvider { + fn drop(&mut self) { + let _ = self.logger.shutdown(); + } +} diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index 3aec3d892f..848aeb25c7 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -21,6 +21,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { codex_protocol::mcp_protocol::InputItem::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ClientRequest::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ServerRequest::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::InitializeResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::NewConversationResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ListConversationsResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ResumeConversationResponse::export_all_to(out_dir)?; @@ -39,6 +40,9 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { codex_protocol::mcp_protocol::GetAuthStatusResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::FuzzyFileSearchParams::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::FuzzyFileSearchResult::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::FuzzyFileSearchResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::GetUserSavedConfigResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::SetDefaultModelResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::GetUserAgentResponse::export_all_to(out_dir)?; @@ -47,6 +51,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { // All notification types reachable from this enum will be generated by // induction, so they do not need to be listed individually. codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::ClientNotification::export_all_to(out_dir)?; generate_index_ts(out_dir)?; @@ -66,7 +71,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { .status() .with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?; if !status.success() { - return Err(anyhow!("Prettier failed with status {}", status)); + return Err(anyhow!("Prettier failed with status {status}")); } } diff --git a/codex-rs/protocol/README.md b/codex-rs/protocol/README.md index 384d0b4859..7120d9f3b3 100644 --- a/codex-rs/protocol/README.md +++ b/codex-rs/protocol/README.md @@ -1,6 +1,6 @@ # codex-protocol -This crate defines the "types" for the protocol used by Codex CLI, which includes both "internal types" for communication between `codex-core` and `codex-tui`, as well as "external types" used with `codex mcp`. +This crate defines the "types" for the protocol used by Codex CLI, which includes both "internal types" for communication between `codex-core` and `codex-tui`, as well as "external types" used with `codex app-server`. This crate should have minimal dependencies. diff --git a/codex-rs/protocol/src/custom_prompts.rs b/codex-rs/protocol/src/custom_prompts.rs index be402051b5..4839cba3c2 100644 --- a/codex-rs/protocol/src/custom_prompts.rs +++ b/codex-rs/protocol/src/custom_prompts.rs @@ -3,9 +3,17 @@ use serde::Serialize; use std::path::PathBuf; use ts_rs::TS; +/// Base namespace for custom prompt slash commands (without trailing colon). +/// Example usage forms constructed in code: +/// - Command token after '/': `"{PROMPTS_CMD_PREFIX}:name"` +/// - Full slash prefix: `"/{PROMPTS_CMD_PREFIX}:"` +pub const PROMPTS_CMD_PREFIX: &str = "prompts"; + #[derive(Serialize, Deserialize, Debug, Clone, TS)] pub struct CustomPrompt { pub name: String, pub path: PathBuf, pub content: String, + pub description: Option, + pub argument_hint: Option, } diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index c63bf2aecc..107055f6e0 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -12,6 +12,7 @@ use crate::protocol::FileChange; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::TurnAbortReason; +use mcp_types::JSONRPCNotification; use mcp_types::RequestId; use serde::Deserialize; use serde::Serialize; @@ -81,7 +82,7 @@ impl GitSha { } } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, TS)] #[serde(rename_all = "lowercase")] pub enum AuthMode { ApiKey, @@ -92,6 +93,11 @@ pub enum AuthMode { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ClientRequest { + Initialize { + #[serde(rename = "id")] + request_id: RequestId, + params: InitializeParams, + }, NewConversation { #[serde(rename = "id")] request_id: RequestId, @@ -184,6 +190,11 @@ pub enum ClientRequest { #[serde(rename = "id")] request_id: RequestId, }, + FuzzyFileSearch { + #[serde(rename = "id")] + request_id: RequestId, + params: FuzzyFileSearchParams, + }, /// Execute a command (argv vector) under the server's sandbox. ExecOneOffCommand { #[serde(rename = "id")] @@ -192,6 +203,27 @@ pub enum ClientRequest { }, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[serde(rename_all = "camelCase")] +pub struct InitializeParams { + pub client_info: ClientInfo, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[serde(rename_all = "camelCase")] +pub struct ClientInfo { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + pub version: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResponse { + pub user_agent: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] #[serde(rename_all = "camelCase")] pub struct NewConversationParams { @@ -662,6 +694,32 @@ pub struct ApplyPatchApprovalResponse { pub decision: ReviewDecision, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct FuzzyFileSearchParams { + pub query: String, + pub roots: Vec, + // if provided, will cancel any previous request that used the same value + #[serde(skip_serializing_if = "Option::is_none")] + pub cancellation_token: Option, +} + +/// Superset of [`codex_file_search::FileMatch`] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +pub struct FuzzyFileSearchResult { + pub root: String, + pub path: String, + pub score: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub indices: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +pub struct FuzzyFileSearchResponse { + pub files: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct LoginChatGptCompleteNotification { @@ -671,6 +729,33 @@ pub struct LoginChatGptCompleteNotification { pub error: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionConfiguredNotification { + /// Name left as session_id instead of conversation_id for backwards compatibility. + pub session_id: ConversationId, + + /// Tell the client what model is being queried. + pub model: String, + + /// The effort the model is putting into reasoning about the user's request. + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + + /// Identifier of the history log file (inode on Unix, 0 otherwise). + pub history_log_id: u64, + + /// Current number of entries in the history log. + pub history_entry_count: usize, + + /// Optional initial messages (as events) for resumed sessions. + /// When present, UIs can use these to seed the history. + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_messages: Option>, + + pub rollout_path: PathBuf, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct AuthStatusChangeNotification { @@ -679,7 +764,8 @@ pub struct AuthStatusChangeNotification { pub auth_method: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS, Display)] +/// Notification sent from the server to the client. +#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)] #[serde(tag = "method", content = "params", rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum ServerNotification { @@ -688,6 +774,9 @@ pub enum ServerNotification { /// ChatGPT login flow completed LoginChatGptComplete(LoginChatGptCompleteNotification), + + /// The special session configured event for a new or resumed conversation. + SessionConfigured(SessionConfiguredNotification), } impl ServerNotification { @@ -695,10 +784,27 @@ impl ServerNotification { match self { ServerNotification::AuthStatusChange(params) => serde_json::to_value(params), ServerNotification::LoginChatGptComplete(params) => serde_json::to_value(params), + ServerNotification::SessionConfigured(params) => serde_json::to_value(params), } } } +impl TryFrom for ServerNotification { + type Error = serde_json::Error; + + fn try_from(value: JSONRPCNotification) -> Result { + serde_json::from_value(serde_json::to_value(value)?) + } +} + +/// Notification sent from the client to the server. +#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)] +#[serde(tag = "method", content = "params", rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum ClientNotification { + Initialized, +} + #[cfg(test)] mod tests { use super::*; @@ -764,4 +870,17 @@ mod tests { ); Ok(()) } + + #[test] + fn serialize_client_notification() -> Result<()> { + let notification = ClientNotification::Initialized; + // Note there is no "params" field for this notification. + assert_eq!( + json!({ + "method": "initialized", + }), + serde_json::to_value(¬ification)?, + ); + Ok(()) + } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index bf0b192548..01b4eb3a5a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1206,7 +1206,7 @@ pub struct SessionConfiguredEvent { } /// User's decision in response to an ExecApprovalRequest. -#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS)] +#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Display, TS)] #[serde(rename_all = "snake_case")] pub enum ReviewDecision { /// User has approved this command and the agent should execute it. diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 51f5c23578..2e89288b02 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -77,6 +77,7 @@ tokio-stream = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } +opentelemetry-appender-tracing = { workspace = true } unicode-segmentation = { workspace = true } unicode-width = { workspace = true } url = { workspace = true } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index b3c1eea6d9..ec231437b0 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,4 +1,3 @@ -use codex_core::protocol::TokenUsageInfo; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -8,6 +7,7 @@ use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Margin; use ratatui::layout::Rect; +use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -19,15 +19,25 @@ use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; +use super::footer::FooterMode; use super::footer::FooterProps; +use super::footer::esc_hint_mode; +use super::footer::footer_height; use super::footer::render_footer; +use super::footer::reset_mode_after_activity; +use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use crate::bottom_pane::paste_burst::FlushResult; +use crate::bottom_pane::prompt_args::expand_custom_prompt; +use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; +use crate::bottom_pane::prompt_args::parse_slash_name; +use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; use crate::slash_command::SlashCommand; use crate::style::user_message_style; use crate::terminal_palette; use codex_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -74,7 +84,6 @@ pub(crate) struct ChatComposer { dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, - token_usage_info: Option, has_focus: bool, attached_images: Vec, placeholder_text: String, @@ -84,6 +93,8 @@ pub(crate) struct ChatComposer { // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, custom_prompts: Vec, + footer_mode: FooterMode, + footer_hint_override: Option>, } /// Popup state – at most one can be visible at any time. @@ -93,7 +104,7 @@ enum ActivePopup { File(FileSearchPopup), } -const FOOTER_HINT_HEIGHT: u16 = 1; +const FOOTER_SPACING_HEIGHT: u16 = 0; impl ChatComposer { pub fn new( @@ -117,7 +128,6 @@ impl ChatComposer { dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), - token_usage_info: None, has_focus: has_input_focus, attached_images: Vec::new(), placeholder_text, @@ -125,6 +135,8 @@ impl ChatComposer { paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), + footer_mode: FooterMode::ShortcutPrompt, + footer_hint_override: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -132,26 +144,37 @@ impl ChatComposer { } pub fn desired_height(&self, width: u16) -> u16 { + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let footer_total_height = footer_hint_height + footer_spacing; self.textarea .desired_height(width.saturating_sub(LIVE_PREFIX_COLS)) + 2 + match &self.active_popup { - ActivePopup::None => FOOTER_HINT_HEIGHT, + ActivePopup::None => footer_total_height, ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), } } fn layout_areas(&self, area: Rect) -> [Rect; 3] { + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let footer_total_height = footer_hint_height + footer_spacing; let popup_constraint = match &self.active_popup { ActivePopup::Command(popup) => { Constraint::Max(popup.calculate_required_height(area.width)) } ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), - ActivePopup::None => Constraint::Max(FOOTER_HINT_HEIGHT), + ActivePopup::None => Constraint::Max(footer_total_height), }; let mut area = area; - // Leave an empty row at the top, unless there isn't room. if area.height > 1 { area.height -= 1; area.y += 1; @@ -164,6 +187,14 @@ impl ChatComposer { [composer_rect, textarea_rect, popup_rect] } + fn footer_spacing(footer_hint_height: u16) -> u16 { + if footer_hint_height == 0 { + 0 + } else { + FOOTER_SPACING_HEIGHT + } + } + pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { let [_, textarea_rect, _] = self.layout_areas(area); let state = *self.textarea_state.borrow(); @@ -175,13 +206,6 @@ impl ChatComposer { self.textarea.is_empty() } - /// Update the cached *context-left* percentage and refresh the placeholder - /// text. The UI relies on the placeholder to convey the remaining - /// context when the composer is empty. - pub(crate) fn set_token_usage(&mut self, token_info: Option) { - self.token_usage_info = token_info; - } - /// Record the history metadata advertised by `SessionConfiguredEvent` so /// that the composer can navigate cross-session history. pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { @@ -256,6 +280,12 @@ impl ChatComposer { } } + /// Override the footer hint items displayed beneath the composer. Passing + /// `None` restores the default shortcut footer. + pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { + self.footer_hint_override = items; + } + /// Replace the entire composer content with `text` and reset cursor. pub(crate) fn set_text_content(&mut self, text: String) { // Clear any existing content, placeholders, and attachments first. @@ -319,6 +349,11 @@ impl ChatComposer { pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) { self.ctrl_c_quit_hint = show; + if show { + self.footer_mode = FooterMode::CtrlCReminder; + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } self.set_has_focus(has_focus); } @@ -354,6 +389,18 @@ impl ChatComposer { /// Handle key event when the slash-command popup is visible. fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } let ActivePopup::Command(popup) = &mut self.active_popup else { unreachable!(); }; @@ -387,6 +434,7 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); if let Some(sel) = popup.selected_item() { + let mut cursor_target: Option = None; match sel { CommandItem::Builtin(cmd) => { let starts_with_cmd = first_line @@ -395,21 +443,29 @@ impl ChatComposer { if !starts_with_cmd { self.textarea.set_text(&format!("/{} ", cmd.command())); } + if !self.textarea.text().is_empty() { + cursor_target = Some(self.textarea.text().len()); + } } CommandItem::UserPrompt(idx) => { - if let Some(name) = popup.prompt_name(idx) { - let starts_with_cmd = - first_line.trim_start().starts_with(&format!("/{name}")); + if let Some(prompt) = popup.prompt(idx) { + let name = prompt.name.clone(); + let starts_with_cmd = first_line + .trim_start() + .starts_with(format!("/{PROMPTS_CMD_PREFIX}:{name}").as_str()); if !starts_with_cmd { - self.textarea.set_text(&format!("/{name} ")); + self.textarea.set_text( + format!("/{PROMPTS_CMD_PREFIX}:{name} ").as_str(), + ); + } + if !self.textarea.text().is_empty() { + cursor_target = Some(self.textarea.text().len()); } } } } - // After completing the command, move cursor to the end. - if !self.textarea.text().is_empty() { - let end = self.textarea.text().len(); - self.textarea.set_cursor(end); + if let Some(pos) = cursor_target { + self.textarea.set_cursor(pos); } } (InputResult::None, true) @@ -419,26 +475,51 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - if let Some(sel) = popup.selected_item() { - // Clear textarea so no residual text remains. + // If the current line starts with a custom prompt name and includes + // positional args for a numeric-style template, expand and submit + // immediately regardless of the popup selection. + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if let Some((name, _rest)) = parse_slash_name(first_line) + && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) + && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name) + && let Some(expanded) = + expand_if_numeric_with_positional_args(prompt, first_line) + { self.textarea.set_text(""); - // Capture any needed data from popup before clearing it. - let prompt_content = match sel { - CommandItem::UserPrompt(idx) => { - popup.prompt_content(idx).map(str::to_string) - } - _ => None, - }; - // Hide popup since an action has been dispatched. - self.active_popup = ActivePopup::None; + return (InputResult::Submitted(expanded), true); + } + if let Some(sel) = popup.selected_item() { match sel { CommandItem::Builtin(cmd) => { + self.textarea.set_text(""); return (InputResult::Command(cmd), true); } - CommandItem::UserPrompt(_) => { - if let Some(contents) = prompt_content { - return (InputResult::Submitted(contents), true); + CommandItem::UserPrompt(idx) => { + if let Some(prompt) = popup.prompt(idx) { + let has_numeric = prompt_has_numeric_placeholders(&prompt.content); + + if !has_numeric { + // No placeholders at all: auto-submit the literal content + self.textarea.set_text(""); + return (InputResult::Submitted(prompt.content.clone()), true); + } + // Numeric placeholders present. + // If the user already typed positional args on the first line, + // expand immediately and submit; otherwise insert "/name " so + // they can type args. + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if let Some(expanded) = + expand_if_numeric_with_positional_args(prompt, first_line) + { + self.textarea.set_text(""); + return (InputResult::Submitted(expanded), true); + } else { + let name = prompt.name.clone(); + let text = format!("/{PROMPTS_CMD_PREFIX}:{name} "); + self.textarea.set_text(&text); + self.textarea.set_cursor(self.textarea.text().len()); + } } return (InputResult::None, true); } @@ -450,6 +531,7 @@ impl ChatComposer { input => self.handle_input_basic(input), } } + #[inline] fn clamp_to_char_boundary(text: &str, pos: usize) -> usize { let mut p = pos.min(text.len()); @@ -478,6 +560,18 @@ impl ChatComposer { /// Handle key events when file search popup is visible. fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } let ActivePopup::File(popup) = &mut self.active_popup else { unreachable!(); }; @@ -714,21 +808,43 @@ impl ChatComposer { .unwrap_or(after_cursor.len()); let end_idx = safe_cursor + end_rel_idx; + // If the path contains whitespace, wrap it in double quotes so the + // local prompt arg parser treats it as a single argument. Avoid adding + // quotes when the path already contains one to keep behavior simple. + let needs_quotes = path.chars().any(char::is_whitespace); + let inserted = if needs_quotes && !path.contains('"') { + format!("\"{path}\"") + } else { + path.to_string() + }; + // Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space. let mut new_text = - String::with_capacity(text.len() - (end_idx - start_idx) + path.len() + 1); + String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); new_text.push_str(&text[..start_idx]); - new_text.push_str(path); + new_text.push_str(&inserted); new_text.push(' '); new_text.push_str(&text[end_idx..]); self.textarea.set_text(&new_text); - let new_cursor = start_idx.saturating_add(path.len()).saturating_add(1); + let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } match key_event { KeyEvent { code: KeyCode::Char('d'), @@ -809,6 +925,7 @@ impl ChatComposer { if self .paste_burst .newline_should_insert_instead_of_submit(now) + && !in_slash_context { self.textarea.insert_str("\n"); self.paste_burst.extend_window(now); @@ -828,6 +945,13 @@ impl ChatComposer { // If there is neither text nor attachments, suppress submission entirely. let has_attachments = !self.attached_images.is_empty(); text = text.trim().to_string(); + + if let Some(expanded) = + expand_custom_prompt(&text, &self.custom_prompts).unwrap_or_default() + { + text = expanded; + } + if text.is_empty() && !has_attachments { return (InputResult::None, true); } @@ -872,6 +996,10 @@ impl ChatComposer { let now = Instant::now(); self.handle_paste_burst_flush(now); + if !matches!(input.code, KeyCode::Esc) { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + // If we're capturing a burst and receive Enter, accumulate it instead of inserting. if matches!(input.code, KeyCode::Enter) && self.paste_burst.is_active() @@ -1145,22 +1273,97 @@ impl ChatComposer { false } + fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { + if key_event.kind != KeyEventKind::Press { + return false; + } + + let toggles = matches!( + key_event, + KeyEvent { + code: KeyCode::Char('?'), + modifiers: KeyModifiers::NONE, + .. + } if self.is_empty() + ); + + if !toggles { + return false; + } + + let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint); + let changed = next != self.footer_mode; + self.footer_mode = next; + changed + } + + fn footer_props(&self) -> FooterProps { + FooterProps { + mode: self.footer_mode(), + esc_backtrack_hint: self.esc_backtrack_hint, + use_shift_enter_hint: self.use_shift_enter_hint, + is_task_running: self.is_task_running, + } + } + + fn footer_mode(&self) -> FooterMode { + match self.footer_mode { + FooterMode::EscHint => FooterMode::EscHint, + FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, + FooterMode::CtrlCReminder => FooterMode::CtrlCReminder, + FooterMode::ShortcutPrompt if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder, + FooterMode::ShortcutPrompt if !self.is_empty() => FooterMode::Empty, + other => other, + } + } + + fn custom_footer_height(&self) -> Option { + self.footer_hint_override + .as_ref() + .map(|items| if items.is_empty() { 0 } else { 1 }) + } + /// Synchronize `self.command_popup` with the current text in the /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. fn sync_command_popup(&mut self) { - let first_line = self.textarea.text().lines().next().unwrap_or(""); - let input_starts_with_slash = first_line.starts_with('/'); + // Determine whether the caret is inside the initial '/name' token on the first line. + let text = self.textarea.text(); + let first_line_end = text.find('\n').unwrap_or(text.len()); + let first_line = &text[..first_line_end]; + let cursor = self.textarea.cursor(); + let caret_on_first_line = cursor <= first_line_end; + + let is_editing_slash_command_name = if first_line.starts_with('/') && caret_on_first_line { + // Compute the end of the initial '/name' token (name may be empty yet). + let token_end = first_line + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(i, _)| i) + .unwrap_or(first_line.len()); + cursor <= token_end + } else { + false + }; + // If the cursor is currently positioned within an `@token`, prefer the + // file-search popup over the slash popup so users can insert a file path + // as an argument to the command (e.g., "/review @docs/..."). + if Self::current_at_token(&self.textarea).is_some() { + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.active_popup = ActivePopup::None; + } + return; + } match &mut self.active_popup { ActivePopup::Command(popup) => { - if input_starts_with_slash { + if is_editing_slash_command_name { popup.on_composer_text_change(first_line.to_string()); } else { self.active_popup = ActivePopup::None; } } _ => { - if input_starts_with_slash { + if is_editing_slash_command_name { let mut command_popup = CommandPopup::new(self.custom_prompts.clone()); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); @@ -1232,18 +1435,17 @@ impl ChatComposer { pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { self.esc_backtrack_hint = show; + if show { + self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } } } impl WidgetRef for ChatComposer { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area); - if !matches!(self.active_popup, ActivePopup::None) { - buf.set_style( - popup_rect, - user_message_style(terminal_palette::default_bg()), - ); - } match &self.active_popup { ActivePopup::Command(popup) => { popup.render_ref(popup_rect, buf); @@ -1252,20 +1454,42 @@ impl WidgetRef for ChatComposer { popup.render_ref(popup_rect, buf); } ActivePopup::None => { - let mut hint_rect = popup_rect; - hint_rect.x += 2; - hint_rect.width = hint_rect.width.saturating_sub(2); - render_footer( - hint_rect, - buf, - FooterProps { - ctrl_c_quit_hint: self.ctrl_c_quit_hint, - is_task_running: self.is_task_running, - esc_backtrack_hint: self.esc_backtrack_hint, - use_shift_enter_hint: self.use_shift_enter_hint, - token_usage_info: self.token_usage_info.as_ref(), - }, - ); + let footer_props = self.footer_props(); + let custom_height = self.custom_footer_height(); + let footer_hint_height = + custom_height.unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { + let [_, hint_rect] = Layout::vertical([ + Constraint::Length(footer_spacing), + Constraint::Length(footer_hint_height), + ]) + .areas(popup_rect); + hint_rect + } else { + popup_rect + }; + if let Some(items) = self.footer_hint_override.as_ref() { + if !items.is_empty() { + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(Span::styled(key.clone(), Style::default().bold())); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + let mut custom_rect = hint_rect; + if custom_rect.width > 2 { + custom_rect.x += 2; + custom_rect.width = custom_rect.width.saturating_sub(2); + } + Line::from(spans).render_ref(custom_rect, buf); + } + } else { + render_footer(hint_rect, buf, footer_props); + } } } let style = user_message_style(terminal_palette::default_bg()); @@ -1304,6 +1528,8 @@ mod tests { use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::AttachedImage; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; + use crate::bottom_pane::footer::footer_height; + use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -1334,7 +1560,7 @@ mod tests { let mut hint_row: Option<(u16, String)> = None; for y in 0..area.height { let row = row_to_string(y); - if row.contains(" send") { + if row.contains("? for shortcuts") { hint_row = Some((y, row)); break; } @@ -1361,6 +1587,153 @@ mod tests { ); } + fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) + where + F: FnOnce(&mut ChatComposer), + { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let width = 100; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + enhanced_keys_supported, + "Ask Codex to do anything".to_string(), + false, + ); + setup(&mut composer); + let footer_props = composer.footer_props(); + let footer_lines = footer_height(footer_props); + let footer_spacing = ChatComposer::footer_spacing(footer_lines); + let height = footer_lines + footer_spacing + 8; + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|f| f.render_widget_ref(composer, f.area())) + .unwrap(); + insta::assert_snapshot!(name, terminal.backend()); + } + + #[test] + fn footer_mode_snapshots() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| { + composer.set_esc_backtrack_hint(true); + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + }); + + snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| { + composer.set_ctrl_c_quit_hint(true, true); + }); + + snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| { + composer.set_task_running(true); + composer.set_ctrl_c_quit_hint(true, true); + }); + + snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| { + composer.set_ctrl_c_quit_hint(true, true); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }); + + snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }); + + snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| { + composer.set_esc_backtrack_hint(true); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }); + + snapshot_composer_state( + "footer_mode_overlay_then_external_esc_hint", + true, + |composer| { + let _ = composer + .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + composer.set_esc_backtrack_hint(true); + }, + ); + + snapshot_composer_state("footer_mode_hidden_while_typing", true, |composer| { + type_chars_humanlike(composer, &['h']); + }); + } + + #[test] + fn question_mark_only_toggles_on_first_char() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert!(needs_redraw, "toggling overlay should request redraw"); + assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + + // Toggle back to prompt mode so subsequent typing captures characters. + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(composer.footer_mode, FooterMode::ShortcutPrompt); + + type_chars_humanlike(&mut composer, &['h']); + assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.footer_mode(), FooterMode::Empty); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert!(needs_redraw, "typing should still mark the view dirty"); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let _ = composer.flush_paste_burst_if_due(); + assert_eq!(composer.textarea.text(), "h?"); + assert_eq!(composer.footer_mode, FooterMode::ShortcutPrompt); + assert_eq!(composer.footer_mode(), FooterMode::Empty); + } + + #[test] + fn shortcut_overlay_persists_while_task_running() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + + composer.set_task_running(true); + + assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay); + } + #[test] fn test_current_at_token_basic_cases() { let test_cases = vec![ @@ -1793,6 +2166,22 @@ mod tests { assert!(composer.textarea.is_empty(), "composer should be cleared"); } + #[test] + fn extract_args_supports_quoted_paths_single_arg() { + let args = extract_positional_args_for_prompt_line( + "/prompts:review \"docs/My File.md\"", + "review", + ); + assert_eq!(args, vec!["docs/My File.md".to_string()]); + } + + #[test] + fn extract_args_supports_mixed_quoted_and_unquoted() { + let args = + extract_positional_args_for_prompt_line("/prompts:cmd \"with spaces\" simple", "cmd"); + assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]); + } + #[test] fn slash_tab_completion_moves_cursor_to_end() { use crossterm::event::KeyCode; @@ -2240,7 +2629,7 @@ mod tests { } #[test] - fn selecting_custom_prompt_submits_file_contents() { + fn selecting_custom_prompt_without_args_submits_content() { let prompt_text = "Hello from saved prompt"; let (tx, _rx) = unbounded_channel::(); @@ -2258,17 +2647,170 @@ mod tests { name: "my-prompt".to_string(), path: "/tmp/my-prompt.md".to_string().into(), content: prompt_text.to_string(), + description: None, + argument_hint: None, }]); type_chars_humanlike( &mut composer, - &['/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't'], + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', + 'p', 't', + ], ); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::Submitted(prompt_text.to_string()), result); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn selecting_custom_prompt_with_args_expands_placeholders() { + // Support $1..$9 and $ARGUMENTS in prompt content. + let prompt_text = "Header: $1\nArgs: $ARGUMENTS\nNinth: $9\n"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + // Type the slash command with two args and hit Enter to submit. + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', + 'p', 't', ' ', 'f', 'o', 'o', ' ', 'b', 'a', 'r', + ], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string(); + assert_eq!(InputResult::Submitted(expected), result); + } + + #[test] + fn selecting_custom_prompt_with_no_args_inserts_template() { + let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "p".to_string(), + path: "/tmp/p.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &['/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p'], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // With no args typed, selecting the prompt inserts the command template + // and does not submit immediately. + assert_eq!(InputResult::None, result); + assert_eq!("/prompts:p ", composer.textarea.text()); + } + + #[test] + fn selecting_custom_prompt_preserves_literal_dollar_dollar() { + // '$$' should remain untouched. + let prompt_text = "Cost: $$ and first: $1"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "price".to_string(), + path: "/tmp/price.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p', 'r', 'i', 'c', 'e', ' ', 'x', + ], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + InputResult::Submitted("Cost: $$ and first: x".to_string()), + result + ); + } + + #[test] + fn selecting_custom_prompt_reuses_cached_arguments_join() { + let prompt_text = "First: $ARGUMENTS\nSecond: $ARGUMENTS"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "repeat".to_string(), + path: "/tmp/repeat.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'r', 'e', 'p', 'e', 'a', 't', ' ', + 'o', 'n', 'e', ' ', 't', 'w', 'o', + ], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let expected = "First: one two\nSecond: one two".to_string(); + assert_eq!(InputResult::Submitted(expected), result); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 32216f0776..c493958272 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -10,6 +10,7 @@ use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; use codex_common::fuzzy_match::fuzzy_match; use codex_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; /// A selectable item in the popup: either a built-in command or a user prompt. @@ -53,12 +54,8 @@ impl CommandPopup { self.prompts = prompts; } - pub(crate) fn prompt_name(&self, idx: usize) -> Option<&str> { - self.prompts.get(idx).map(|p| p.name.as_str()) - } - - pub(crate) fn prompt_content(&self, idx: usize) -> Option<&str> { - self.prompts.get(idx).map(|p| p.content.as_str()) + pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> { + self.prompts.get(idx) } /// Update the filter string based on the current composer text. The text @@ -124,8 +121,12 @@ impl CommandPopup { out.push((CommandItem::Builtin(*cmd), Some(indices), score)); } } + // Support both search styles: + // - Typing "name" should surface "/prompts:name" results. + // - Typing "prompts:name" should also work. for (idx, p) in self.prompts.iter().enumerate() { - if let Some((indices, score)) = fuzzy_match(&p.name, filter) { + let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name); + if let Some((indices, score)) = fuzzy_match(&display, filter) { out.push((CommandItem::UserPrompt(idx), Some(indices), score)); } } @@ -162,7 +163,7 @@ impl CommandPopup { (format!("/{}", cmd.command()), cmd.description().to_string()) } CommandItem::UserPrompt(i) => ( - format!("/{}", self.prompts[i].name), + format!("/{PROMPTS_CMD_PREFIX}:{}", self.prompts[i].name), "send saved prompt".to_string(), ), }; @@ -218,7 +219,6 @@ impl WidgetRef for CommandPopup { #[cfg(test)] mod tests { use super::*; - use std::string::ToString; #[test] fn filter_includes_init_when_typing_prefix() { @@ -276,11 +276,15 @@ mod tests { name: "foo".to_string(), path: "/tmp/foo.md".to_string().into(), content: "hello from foo".to_string(), + description: None, + argument_hint: None, }, CustomPrompt { name: "bar".to_string(), path: "/tmp/bar.md".to_string().into(), content: "hello from bar".to_string(), + description: None, + argument_hint: None, }, ]; let popup = CommandPopup::new(prompts); @@ -288,7 +292,7 @@ mod tests { let mut prompt_names: Vec = items .into_iter() .filter_map(|it| match it { - CommandItem::UserPrompt(i) => popup.prompt_name(i).map(ToString::to_string), + CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()), _ => None, }) .collect(); @@ -303,10 +307,12 @@ mod tests { name: "init".to_string(), path: "/tmp/init.md".to_string().into(), content: "should be ignored".to_string(), + description: None, + argument_hint: None, }]); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { - CommandItem::UserPrompt(i) => popup.prompt_name(i) == Some("init"), + CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"), _ => false, }); assert!( diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 77e2c106e5..06a5b5c7e0 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,180 +1,238 @@ -use codex_core::protocol::TokenUsageInfo; -use codex_protocol::num_format::format_si_suffix; +use crate::ui_consts::FOOTER_INDENT_COLS; use crossterm::event::KeyCode; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; use ratatui::widgets::WidgetRef; - -use crate::key_hint; +use std::iter; #[derive(Clone, Copy, Debug)] -pub(crate) struct FooterProps<'a> { - pub(crate) ctrl_c_quit_hint: bool, - pub(crate) is_task_running: bool, +pub(crate) struct FooterProps { + pub(crate) mode: FooterMode, pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, - pub(crate) token_usage_info: Option<&'a TokenUsageInfo>, + pub(crate) is_task_running: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum FooterMode { + CtrlCReminder, + ShortcutPrompt, + ShortcutOverlay, + EscHint, + Empty, +} + +pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { + if ctrl_c_hint && matches!(current, FooterMode::CtrlCReminder) { + return current; + } + + match current { + FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutPrompt, + _ => FooterMode::ShortcutOverlay, + } +} + +pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode { + if is_task_running { + current + } else { + FooterMode::EscHint + } +} + +pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { + match current { + FooterMode::EscHint + | FooterMode::ShortcutOverlay + | FooterMode::CtrlCReminder + | FooterMode::Empty => FooterMode::ShortcutPrompt, + other => other, + } +} + +pub(crate) fn footer_height(props: FooterProps) -> u16 { + footer_lines(props).len() as u16 +} + +pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { + let lines = footer_lines(props); + for (idx, line) in lines.into_iter().enumerate() { + let y = area.y + idx as u16; + if y >= area.y + area.height { + break; + } + let row = Rect::new(area.x, y, area.width, 1); + line.render_ref(row, buf); + } +} + +fn footer_lines(props: FooterProps) -> Vec> { + match props.mode { + FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState { + is_task_running: props.is_task_running, + })], + FooterMode::ShortcutPrompt => vec![dim_line(indent_text("? for shortcuts"))], + FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + }), + FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], + FooterMode::Empty => Vec::new(), + } } #[derive(Clone, Copy, Debug)] struct CtrlCReminderState { - pub(crate) is_task_running: bool, + is_task_running: bool, } #[derive(Clone, Copy, Debug)] struct ShortcutsState { - pub(crate) use_shift_enter_hint: bool, - pub(crate) esc_backtrack_hint: bool, + use_shift_enter_hint: bool, + esc_backtrack_hint: bool, } -#[derive(Clone, Copy, Debug)] -enum FooterContent { - Shortcuts(ShortcutsState), - CtrlCReminder(CtrlCReminderState), -} - -pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps<'_>) { - let content = if props.ctrl_c_quit_hint { - FooterContent::CtrlCReminder(CtrlCReminderState { - is_task_running: props.is_task_running, - }) +fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { + let action = if state.is_task_running { + "interrupt" } else { - FooterContent::Shortcuts(ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - }) + "quit" }; - - let mut spans = footer_spans(content); - if let Some(token_usage_info) = props.token_usage_info { - append_token_usage_spans(&mut spans, token_usage_info); - } - - let spans = spans - .into_iter() - .map(|span| span.patch_style(Style::default().dim())) - .collect::>(); - Line::from(spans).render_ref(area, buf); + let text = format!("ctrl + c again to {action}"); + dim_line(indent_text(&text)) } -fn footer_spans(content: FooterContent) -> Vec> { - match content { - FooterContent::Shortcuts(state) => shortcuts_spans(state), - FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state), - } +fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { + let text = if esc_backtrack_hint { + "esc again to edit previous message" + } else { + "esc esc to edit previous message" + }; + dim_line(indent_text(text)) } -fn append_token_usage_spans(spans: &mut Vec>, token_usage_info: &TokenUsageInfo) { - let token_usage = &token_usage_info.total_token_usage; - spans.push(" ".into()); - spans.push( - Span::from(format!( - "{} tokens used", - format_si_suffix(token_usage.blended_total()) - )) - .style(Style::default().add_modifier(Modifier::DIM)), - ); +fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { + let mut commands = String::new(); + let mut newline = String::new(); + let mut file_paths = String::new(); + let mut paste_image = String::new(); + let mut edit_previous = String::new(); + let mut quit = String::new(); + let mut show_transcript = String::new(); - let last_token_usage = &token_usage_info.last_token_usage; - if let Some(context_window) = token_usage_info.model_context_window { - let percent_remaining: u8 = if context_window > 0 { - last_token_usage.percent_of_context_window_remaining(context_window) - } else { - 100 - }; - - let context_style = if percent_remaining < 20 { - Style::default().fg(Color::Yellow) - } else { - Style::default().add_modifier(Modifier::DIM) - }; - spans.push(" ".into()); - spans.push(Span::styled( - format!("{percent_remaining}% context left"), - context_style, - )); - } -} - -fn shortcuts_spans(state: ShortcutsState) -> Vec> { - let mut spans = Vec::new(); for descriptor in SHORTCUTS { - if let Some(segment) = descriptor.footer_segment(state) { - if !segment.prefix.is_empty() { - spans.push(segment.prefix.into()); + if let Some(text) = descriptor.overlay_entry(state) { + match descriptor.id { + ShortcutId::Commands => commands = text, + ShortcutId::InsertNewline => newline = text, + ShortcutId::FilePaths => file_paths = text, + ShortcutId::PasteImage => paste_image = text, + ShortcutId::EditPrevious => edit_previous = text, + ShortcutId::Quit => quit = text, + ShortcutId::ShowTranscript => show_transcript = text, } - spans.push(segment.binding.span()); - spans.push(segment.label.into()); } } - spans + + let ordered = vec![ + commands, + newline, + file_paths, + paste_image, + edit_previous, + quit, + String::new(), + show_transcript, + ]; + + build_columns(ordered) } -fn ctrl_c_reminder_spans(state: CtrlCReminderState) -> Vec> { - let followup = if state.is_task_running { - " to interrupt" - } else { - " to quit" - }; - vec![ - " ".into(), - key_hint::ctrl('C'), - " again".into(), - followup.into(), - ] +fn build_columns(entries: Vec) -> Vec> { + if entries.is_empty() { + return Vec::new(); + } + + const COLUMNS: usize = 2; + const COLUMN_PADDING: [usize; COLUMNS] = [4, 4]; + const COLUMN_GAP: usize = 4; + + let rows = entries.len().div_ceil(COLUMNS); + let target_len = rows * COLUMNS; + let mut entries = entries; + if entries.len() < target_len { + entries.extend(std::iter::repeat_n( + String::new(), + target_len - entries.len(), + )); + } + + let mut column_widths = [0usize; COLUMNS]; + + for (idx, entry) in entries.iter().enumerate() { + let column = idx % COLUMNS; + column_widths[column] = column_widths[column].max(entry.len()); + } + + for (idx, width) in column_widths.iter_mut().enumerate() { + *width += COLUMN_PADDING[idx]; + } + + entries + .chunks(COLUMNS) + .map(|chunk| { + let mut line = String::new(); + for (col, entry) in chunk.iter().enumerate() { + line.push_str(entry); + if col < COLUMNS - 1 { + let target_width = column_widths[col]; + let padding = target_width.saturating_sub(entry.len()) + COLUMN_GAP; + line.push_str(&" ".repeat(padding)); + } + } + let indented = indent_text(&line); + dim_line(indented) + }) + .collect() } -#[derive(Clone, Copy, Debug)] -struct FooterSegment { - prefix: &'static str, - binding: ShortcutBinding, - label: &'static str, +fn indent_text(text: &str) -> String { + let mut indented = String::with_capacity(FOOTER_INDENT_COLS + text.len()); + indented.extend(iter::repeat_n(' ', FOOTER_INDENT_COLS)); + indented.push_str(text); + indented } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +fn dim_line(text: String) -> Line<'static> { + Line::from(text).dim() +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShortcutId { - Send, + Commands, InsertNewline, - ShowTranscript, - Quit, + FilePaths, + PasteImage, EditPrevious, + Quit, + ShowTranscript, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct ShortcutBinding { code: KeyCode, modifiers: KeyModifiers, - display: ShortcutDisplay, + overlay_text: &'static str, condition: DisplayCondition, } impl ShortcutBinding { - fn span(&self) -> Span<'static> { - self.display.into_span() - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShortcutDisplay { - Plain(&'static str), - Ctrl(char), - Shift(char), -} - -impl ShortcutDisplay { - fn into_span(self) -> Span<'static> { - match self { - ShortcutDisplay::Plain(text) => key_hint::plain(text), - ShortcutDisplay::Ctrl(ch) => key_hint::ctrl(ch), - ShortcutDisplay::Shift(ch) => key_hint::shift(ch), - } + fn matches(&self, state: ShortcutsState) -> bool { + self.condition.matches(state) } } @@ -198,49 +256,43 @@ impl DisplayCondition { struct ShortcutDescriptor { id: ShortcutId, bindings: &'static [ShortcutBinding], - footer_label: &'static str, - footer_prefix: &'static str, + prefix: &'static str, + label: &'static str, } impl ShortcutDescriptor { - fn binding_for(&self, state: ShortcutsState) -> Option { - self.bindings - .iter() - .find(|binding| binding.condition.matches(state)) - .copied() + fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { + self.bindings.iter().find(|binding| binding.matches(state)) } - fn should_show(&self, state: ShortcutsState) -> bool { - match self.id { - ShortcutId::EditPrevious => state.esc_backtrack_hint, - _ => true, - } - } - - fn footer_segment(&self, state: ShortcutsState) -> Option { - if !self.should_show(state) { - return None; - } + fn overlay_entry(&self, state: ShortcutsState) -> Option { let binding = self.binding_for(state)?; - Some(FooterSegment { - prefix: self.footer_prefix, - binding, - label: self.footer_label, - }) + let label = match self.id { + ShortcutId::EditPrevious => { + if state.esc_backtrack_hint { + " again to edit previous message" + } else { + " esc to edit previous message" + } + } + _ => self.label, + }; + let text = format!("{}{}{}", self.prefix, binding.overlay_text, label); + Some(text) } } const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { - id: ShortcutId::Send, + id: ShortcutId::Commands, bindings: &[ShortcutBinding { - code: KeyCode::Enter, + code: KeyCode::Char('/'), modifiers: KeyModifiers::NONE, - display: ShortcutDisplay::Plain("⏎"), + overlay_text: "/", condition: DisplayCondition::Always, }], - footer_label: " send ", - footer_prefix: "", + prefix: "", + label: " for commands", }, ShortcutDescriptor { id: ShortcutId::InsertNewline, @@ -248,138 +300,154 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutBinding { code: KeyCode::Enter, modifiers: KeyModifiers::SHIFT, - display: ShortcutDisplay::Shift('⏎'), + overlay_text: "shift + enter", condition: DisplayCondition::WhenShiftEnterHint, }, ShortcutBinding { code: KeyCode::Char('j'), modifiers: KeyModifiers::CONTROL, - display: ShortcutDisplay::Ctrl('J'), + overlay_text: "ctrl + j", condition: DisplayCondition::WhenNotShiftEnterHint, }, ], - footer_label: " newline ", - footer_prefix: "", + prefix: "", + label: " for newline", }, ShortcutDescriptor { - id: ShortcutId::ShowTranscript, + id: ShortcutId::FilePaths, bindings: &[ShortcutBinding { - code: KeyCode::Char('t'), - modifiers: KeyModifiers::CONTROL, - display: ShortcutDisplay::Ctrl('T'), + code: KeyCode::Char('@'), + modifiers: KeyModifiers::NONE, + overlay_text: "@", condition: DisplayCondition::Always, }], - footer_label: " transcript ", - footer_prefix: "", + prefix: "", + label: " for file paths", }, ShortcutDescriptor { - id: ShortcutId::Quit, + id: ShortcutId::PasteImage, bindings: &[ShortcutBinding { - code: KeyCode::Char('c'), + code: KeyCode::Char('v'), modifiers: KeyModifiers::CONTROL, - display: ShortcutDisplay::Ctrl('C'), + overlay_text: "ctrl + v", condition: DisplayCondition::Always, }], - footer_label: " quit", - footer_prefix: "", + prefix: "", + label: " to paste images", }, ShortcutDescriptor { id: ShortcutId::EditPrevious, bindings: &[ShortcutBinding { code: KeyCode::Esc, modifiers: KeyModifiers::NONE, - display: ShortcutDisplay::Plain("Esc"), + overlay_text: "esc", condition: DisplayCondition::Always, }], - footer_label: " edit prev", - footer_prefix: " ", + prefix: "", + label: "", + }, + ShortcutDescriptor { + id: ShortcutId::Quit, + bindings: &[ShortcutBinding { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + overlay_text: "ctrl + c", + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to exit", + }, + ShortcutDescriptor { + id: ShortcutId::ShowTranscript, + bindings: &[ShortcutBinding { + code: KeyCode::Char('t'), + modifiers: KeyModifiers::CONTROL, + overlay_text: "ctrl + t", + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to view transcript", }, ]; #[cfg(test)] mod tests { use super::*; - use codex_core::protocol::TokenUsage; use insta::assert_snapshot; use ratatui::Terminal; use ratatui::backend::TestBackend; - fn snapshot_footer(name: &str, props: FooterProps<'_>) { - let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap(); + fn snapshot_footer(name: &str, props: FooterProps) { + let height = footer_height(props).max(1); + let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap(); terminal .draw(|f| { - let area = Rect::new(0, 0, f.area().width, 1); + let area = Rect::new(0, 0, f.area().width, height); render_footer(area, f.buffer_mut(), props); }) .unwrap(); assert_snapshot!(name, terminal.backend()); } - fn token_usage(total_tokens: u64, last_tokens: u64, context_window: u64) -> TokenUsageInfo { - let usage = TokenUsage { - input_tokens: total_tokens, - cached_input_tokens: 0, - output_tokens: 0, - reasoning_output_tokens: 0, - total_tokens, - }; - let last = TokenUsage { - input_tokens: last_tokens, - cached_input_tokens: 0, - output_tokens: 0, - reasoning_output_tokens: 0, - total_tokens: last_tokens, - }; - TokenUsageInfo { - total_token_usage: usage, - last_token_usage: last, - model_context_window: Some(context_window), - } - } - #[test] fn footer_snapshots() { snapshot_footer( "footer_shortcuts_default", FooterProps { - ctrl_c_quit_hint: false, - is_task_running: false, + mode: FooterMode::ShortcutPrompt, esc_backtrack_hint: false, use_shift_enter_hint: false, - token_usage_info: None, + is_task_running: false, }, ); snapshot_footer( "footer_shortcuts_shift_and_esc", FooterProps { - ctrl_c_quit_hint: false, - is_task_running: false, + mode: FooterMode::ShortcutOverlay, esc_backtrack_hint: true, use_shift_enter_hint: true, - token_usage_info: Some(&token_usage(4_200, 900, 8_000)), + is_task_running: false, }, ); snapshot_footer( "footer_ctrl_c_quit_idle", FooterProps { - ctrl_c_quit_hint: true, - is_task_running: false, + mode: FooterMode::CtrlCReminder, esc_backtrack_hint: false, use_shift_enter_hint: false, - token_usage_info: None, + is_task_running: false, }, ); snapshot_footer( "footer_ctrl_c_quit_running", FooterProps { - ctrl_c_quit_hint: true, - is_task_running: true, + mode: FooterMode::CtrlCReminder, esc_backtrack_hint: false, use_shift_enter_hint: false, - token_usage_info: None, + is_task_running: true, + }, + ); + + snapshot_footer( + "footer_esc_hint_idle", + FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + }, + ); + + snapshot_footer( + "footer_esc_hint_primed", + FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: true, + use_shift_enter_hint: false, + is_task_running: false, }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a01ffb498f..fccf565195 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -4,7 +4,6 @@ use std::path::PathBuf; use crate::app_event_sender::AppEventSender; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; -use codex_core::protocol::TokenUsageInfo; use codex_file_search::FileMatch; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -26,6 +25,7 @@ pub mod custom_prompt_view; mod file_search_popup; mod footer; mod list_selection_view; +mod prompt_args; pub(crate) use list_selection_view::SelectionViewParams; mod paste_burst; pub mod popup_consts; @@ -377,13 +377,6 @@ impl BottomPane { !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() } - /// Update the *context-window remaining* indicator in the composer. This - /// is forwarded directly to the underlying `ChatComposer`. - pub(crate) fn set_token_usage(&mut self, token_info: Option) { - self.composer.set_token_usage(token_info); - self.request_redraw(); - } - pub(crate) fn show_view(&mut self, view: Box) { self.push_view(view); } diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs new file mode 100644 index 0000000000..51c95d9998 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -0,0 +1,161 @@ +use codex_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; +use shlex::Shlex; + +/// Parse a first-line slash command of the form `/name `. +/// Returns `(name, rest_after_name)` if the line begins with `/` and contains +/// a non-empty name; otherwise returns `None`. +pub fn parse_slash_name(line: &str) -> Option<(&str, &str)> { + let stripped = line.strip_prefix('/')?; + let mut name_end = stripped.len(); + for (idx, ch) in stripped.char_indices() { + if ch.is_whitespace() { + name_end = idx; + break; + } + } + let name = &stripped[..name_end]; + if name.is_empty() { + return None; + } + let rest = stripped[name_end..].trim_start(); + Some((name, rest)) +} + +/// Parse positional arguments using shlex semantics (supports quoted tokens). +pub fn parse_positional_args(rest: &str) -> Vec { + Shlex::new(rest).collect() +} + +/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt. +/// +/// If the text does not start with `/prompts:`, or if no prompt named `name` exists, +/// the function returns `Ok(None)`. On success it returns +/// `Ok(Some(expanded))`; otherwise it returns a descriptive error. +pub fn expand_custom_prompt( + text: &str, + custom_prompts: &[CustomPrompt], +) -> Result, ()> { + let Some((name, rest)) = parse_slash_name(text) else { + return Ok(None); + }; + + // Only handle custom prompts when using the explicit prompts prefix with a colon. + let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return Ok(None); + }; + + let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) { + Some(prompt) => prompt, + None => return Ok(None), + }; + // Only support numeric placeholders ($1..$9) and $ARGUMENTS. + if prompt_has_numeric_placeholders(&prompt.content) { + let pos_args: Vec = Shlex::new(rest).collect(); + let expanded = expand_numeric_placeholders(&prompt.content, &pos_args); + return Ok(Some(expanded)); + } + // No recognized placeholders: return the literal content. + Ok(Some(prompt.content.clone())) +} + +/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`. +pub fn prompt_has_numeric_placeholders(content: &str) -> bool { + if content.contains("$ARGUMENTS") { + return true; + } + let bytes = content.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'$' { + let b1 = bytes[i + 1]; + if (b'1'..=b'9').contains(&b1) { + return true; + } + } + i += 1; + } + false +} + +/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name. +/// Returns empty when the command name does not match or when there are no args. +pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> Vec { + let trimmed = line.trim_start(); + let Some(rest) = trimmed.strip_prefix('/') else { + return Vec::new(); + }; + // Require the explicit prompts prefix for custom prompt invocations. + let Some(after_prefix) = rest.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return Vec::new(); + }; + let mut parts = after_prefix.splitn(2, char::is_whitespace); + let cmd = parts.next().unwrap_or(""); + if cmd != prompt_name { + return Vec::new(); + } + let args_str = parts.next().unwrap_or("").trim(); + if args_str.is_empty() { + return Vec::new(); + } + parse_positional_args(args_str) +} + +/// If the prompt only uses numeric placeholders and the first line contains +/// positional args for it, expand and return Some(expanded); otherwise None. +pub fn expand_if_numeric_with_positional_args( + prompt: &CustomPrompt, + first_line: &str, +) -> Option { + if !prompt_has_numeric_placeholders(&prompt.content) { + return None; + } + let args = extract_positional_args_for_prompt_line(first_line, &prompt.name); + if args.is_empty() { + return None; + } + Some(expand_numeric_placeholders(&prompt.content, &args)) +} + +/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`. +pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { + let mut out = String::with_capacity(content.len()); + let mut i = 0; + let mut cached_joined_args: Option = None; + while let Some(off) = content[i..].find('$') { + let j = i + off; + out.push_str(&content[i..j]); + let rest = &content[j..]; + let bytes = rest.as_bytes(); + if bytes.len() >= 2 { + match bytes[1] { + b'$' => { + out.push_str("$$"); + i = j + 2; + continue; + } + b'1'..=b'9' => { + let idx = (bytes[1] - b'1') as usize; + if let Some(val) = args.get(idx) { + out.push_str(val); + } + i = j + 2; + continue; + } + _ => {} + } + } + if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { + if !args.is_empty() { + let joined = cached_joined_args.get_or_insert_with(|| args.join(" ")); + out.push_str(joined); + } + i = j + 1 + "ARGUMENTS".len(); + continue; + } + out.push('$'); + i = j + 1; + } + out.push_str(&content[i..]); + out +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap index 3f57cab0f6..adb764b6c4 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap index beccbaf7c7..db92427864 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1938 expression: terminal.backend() --- " " @@ -11,4 +12,4 @@ expression: terminal.backend() " " " " " " -" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" ? for shortcuts " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap new file mode 100644 index 0000000000..a805fbf9f4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ctrl + c again to interrupt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap new file mode 100644 index 0000000000..750ba10192 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap new file mode 100644 index 0000000000..8c2d2bfd39 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap new file mode 100644 index 0000000000..5ddf39e315 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap new file mode 100644 index 0000000000..8c2d2bfd39 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap new file mode 100644 index 0000000000..dfeb98d619 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› h " +" " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap new file mode 100644 index 0000000000..5ddf39e315 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap new file mode 100644 index 0000000000..170dedc01e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap index 263c7cc549..4237a17ae0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap index beb2d8e464..3edfc2ce22 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap index 41f0f03d8b..402740b8fa 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap index 32482ba1cd..817adb66d9 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -1,7 +1,6 @@ --- source: tui/src/bottom_pane/footer.rs +assertion_line: 389 expression: terminal.backend() --- -" ⌃C again to quit " -" " -" " +" ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap index 8c9c2b0d46..50bf9b622d 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -1,7 +1,6 @@ --- source: tui/src/bottom_pane/footer.rs +assertion_line: 389 expression: terminal.backend() --- -" ⌃C again to interrupt " -" " -" " +" ctrl + c again to interrupt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap new file mode 100644 index 0000000000..172432a38e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/footer.rs +assertion_line: 389 +expression: terminal.backend() +--- +" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap new file mode 100644 index 0000000000..69d79d5327 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/footer.rs +assertion_line: 389 +expression: terminal.backend() +--- +" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap index 8c8a10df85..b2de215488 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -1,7 +1,6 @@ --- source: tui/src/bottom_pane/footer.rs +assertion_line: 389 expression: terminal.backend() --- -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " -" " -" " +" ? for shortcuts " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 9b94d89f98..ffa4c5b048 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -1,7 +1,9 @@ --- source: tui/src/bottom_pane/footer.rs +assertion_line: 389 expression: terminal.backend() --- -"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use" -" " -" " +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 724feb29f2..981e0dfa94 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -395,7 +395,6 @@ impl ChatWidget { pub(crate) fn set_token_info(&mut self, info: Option) { if info.is_some() { - self.bottom_pane.set_token_usage(info.clone()); self.token_info = info; } } @@ -1779,6 +1778,20 @@ impl ChatWidget { pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); + items.push(SelectionItem { + name: "Review against a base branch".to_string(), + description: Some("(PR Style)".into()), + is_current: false, + actions: vec![Box::new({ + let cwd = self.config.cwd.clone(); + move |tx| { + tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); + } + })], + dismiss_on_select: false, + search_value: None, + }); + items.push(SelectionItem { name: "Review uncommitted changes".to_string(), description: None, @@ -1812,20 +1825,6 @@ impl ChatWidget { search_value: None, }); - items.push(SelectionItem { - name: "Review against a base branch".to_string(), - description: None, - is_current: false, - actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); - move |tx| { - tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); - } - })], - dismiss_on_select: false, - search_value: None, - }); - items.push(SelectionItem { name: "Custom review instructions".to_string(), description: None, @@ -1975,7 +1974,6 @@ impl ChatWidget { pub(crate) fn clear_token_usage(&mut self) { self.token_info = None; - self.bottom_pane.set_token_usage(None); } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index 248a57f8f3..e3f7f532a7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -13,5 +13,3 @@ expression: term.backend().vt100().screen().contents() › Summarize recent commits - - ⏎ send ⌃J newline ⌃T transcript ⌃C quit diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap index 9c94206a44..dab45b3b91 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1445 expression: terminal.backend() --- " " @@ -8,5 +9,5 @@ expression: terminal.backend() " " "› Ask Codex to do anything " " " -" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" ? for shortcuts " " " diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 3eddbec561..aa79e980c1 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -412,11 +412,13 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { .unwrap_or(0); last_nonblank_column[y as usize] = x as u16; let (x_abs, y_abs) = a.pos_of(row_start + x + 1); - updates.push(DrawCommand::ClearToEnd { - x: x_abs, - y: y_abs, - bg, - }); + if x < (a.area.width as usize).saturating_sub(1) { + updates.push(DrawCommand::ClearToEnd { + x: x_abs, + y: y_abs, + bg, + }); + } } // Cells invalidated by drawing/replacing preceding multi-width characters: @@ -570,3 +572,38 @@ impl ModifierDiff { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use ratatui::layout::Rect; + + #[test] + fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() { + let area = Rect::new(0, 0, 3, 2); + let previous = Buffer::empty(area); + let mut next = Buffer::empty(area); + + next.cell_mut((2, 0)) + .expect("cell should exist") + .set_symbol("X"); + + let commands = diff_buffers(&previous, &next); + + let clear_count = commands + .iter() + .filter(|command| matches!(command, DrawCommand::ClearToEnd { y, .. } if *y == 0)) + .count(); + assert_eq!( + 0, clear_count, + "expected diff_buffers not to emit ClearToEnd; commands: {commands:?}", + ); + assert!( + commands + .iter() + .any(|command| matches!(command, DrawCommand::Put { x: 2, y: 0, .. })), + "expected diff_buffers to update the final cell; commands: {commands:?}", + ); + } +} diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 27cb53d7f2..daf5956595 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -200,7 +200,7 @@ impl ExecCell { if self.is_active() { spinner(self.active_start_time()) } else { - "•".into() + "•".dim() }, " ".into(), if self.is_active() { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index d0e13003ff..5db6ebe1a7 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -166,7 +166,7 @@ impl HistoryCell for ReasoningSummaryCell { word_wrap_lines( &summary_lines, RtOptions::new(width as usize) - .initial_indent("• ".into()) + .initial_indent("• ".dim().into()) .subsequent_indent(" ".into()), ) } @@ -200,7 +200,7 @@ impl HistoryCell for AgentMessageCell { &self.lines, RtOptions::new(width as usize) .initial_indent(if self.is_first_line { - "• ".into() + "• ".dim().into() } else { " ".into() }) @@ -875,7 +875,7 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Server: ".into(), server.clone().into()].into()); match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, .. } => { + McpServerTransportConfig::Stdio { command, args, env } => { let args_suffix = if args.is_empty() { String::new() } else { @@ -883,6 +883,15 @@ pub(crate) fn new_mcp_tools_output( }; let cmd_display = format!("{command}{args_suffix}"); lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); + + if let Some(env) = env.as_ref() + && !env.is_empty() + { + let mut env_pairs: Vec = + env.iter().map(|(k, v)| format!("{k}={v}")).collect(); + env_pairs.sort(); + lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); + } } McpServerTransportConfig::StreamableHttp { url, .. } => { lines.push(vec![" • URL: ".into(), url.clone().into()].into()); @@ -901,7 +910,7 @@ pub(crate) fn new_mcp_tools_output( } pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHistoryCell { - let mut line = vec!["• ".into(), message.into()]; + let mut line = vec!["• ".dim(), message.into()]; if let Some(hint) = hint { line.push(" ".into()); line.push(hint.dark_gray()); @@ -964,7 +973,7 @@ impl HistoryCell for PlanUpdateCell { }; let mut lines: Vec> = vec![]; - lines.push(vec!["• ".into(), "Updated Plan".bold()].into()); + lines.push(vec!["• ".dim(), "Updated Plan".bold()].into()); let mut indented_lines = vec![]; let note = self @@ -1036,7 +1045,7 @@ pub(crate) fn new_proposed_command(command: &[String]) -> PlainHistoryCell { let cmd = strip_bash_lc_and_escape(command); let mut lines: Vec> = Vec::new(); - lines.push(Line::from(vec!["• ".into(), "Proposed Command".bold()])); + lines.push(Line::from(vec!["• ".dim(), "Proposed Command".bold()])); let highlighted_lines = crate::render::highlight::highlight_bash_to_lines(&cmd); let initial_prefix: Span<'static> = " └ ".dim(); diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 80f91d50b2..f68a493e9c 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -10,20 +10,6 @@ const ALT_PREFIX: &str = "⌥"; #[cfg(all(not(test), not(target_os = "macos")))] const ALT_PREFIX: &str = "Alt+"; -#[cfg(test)] -const CTRL_PREFIX: &str = "⌃"; -#[cfg(all(not(test), target_os = "macos"))] -const CTRL_PREFIX: &str = "⌃"; -#[cfg(all(not(test), not(target_os = "macos")))] -const CTRL_PREFIX: &str = "Ctrl+"; - -#[cfg(test)] -const SHIFT_PREFIX: &str = "⇧"; -#[cfg(all(not(test), target_os = "macos"))] -const SHIFT_PREFIX: &str = "⇧"; -#[cfg(all(not(test), not(target_os = "macos")))] -const SHIFT_PREFIX: &str = "Shift+"; - fn key_hint_style() -> Style { Style::default().bold() } @@ -32,18 +18,6 @@ fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> { Span::styled(format!("{prefix}{key}"), key_hint_style()) } -pub(crate) fn ctrl(key: impl Display) -> Span<'static> { - modifier_span(CTRL_PREFIX, key) -} - pub(crate) fn alt(key: impl Display) -> Span<'static> { modifier_span(ALT_PREFIX, key) } - -pub(crate) fn shift(key: impl Display) -> Span<'static> { - modifier_span(SHIFT_PREFIX, key) -} - -pub(crate) fn plain(key: impl Display) -> Span<'static> { - Span::styled(format!("{key}"), key_hint_style()) -} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e959973b22..1d66251ff0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -20,6 +20,7 @@ use codex_core::protocol::SandboxPolicy; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; use codex_protocol::mcp_protocol::AuthMode; +use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; use std::fs::OpenOptions; use std::path::PathBuf; use tracing::error; @@ -54,6 +55,7 @@ mod markdown_render; mod markdown_stream; pub mod onboarding; mod pager_overlay; +pub mod public_widgets; mod render; mod resume_picker; mod session_log; @@ -81,6 +83,9 @@ use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::tui::Tui; pub use cli::Cli; +pub use markdown_render::render_markdown_text; +pub use public_widgets::composer_input::ComposerAction; +pub use public_widgets::composer_input::ComposerInput; // (tests access modules directly within the crate) @@ -239,7 +244,29 @@ pub async fn run_main( .map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?; } - let _ = tracing_subscriber::registry().with(file_layer).try_init(); + let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION")); + + #[allow(clippy::print_stderr)] + let otel = match otel { + Ok(otel) => otel, + Err(e) => { + eprintln!("Could not create otel exporter: {e}"); + std::process::exit(1); + } + }; + + if let Some(provider) = otel.as_ref() { + let otel_layer = OpenTelemetryTracingBridge::new(&provider.logger).with_filter( + tracing_subscriber::filter::filter_fn(codex_core::otel_init::codex_export_filter), + ); + + let _ = tracing_subscriber::registry() + .with(file_layer) + .with(otel_layer) + .try_init(); + } else { + let _ = tracing_subscriber::registry().with(file_layer).try_init(); + }; run_ratatui_app(cli, config, active_profile, should_show_trust_screen) .await diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index cac40cffde..4839346380 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -33,7 +33,7 @@ impl IndentContext { } #[allow(dead_code)] -pub(crate) fn render_markdown_text(input: &str) -> Text<'static> { +pub fn render_markdown_text(input: &str) -> Text<'static> { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(input, options); diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs new file mode 100644 index 0000000000..457e37fe06 --- /dev/null +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -0,0 +1,128 @@ +//! Public wrapper around the internal ChatComposer for simple, reusable text input. +//! +//! This exposes a minimal interface suitable for other crates (e.g., +//! codex-cloud-tasks) to reuse the mature composer behavior: multi-line input, +//! paste heuristics, Enter-to-submit, and Shift+Enter for newline. + +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; +use std::time::Duration; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ChatComposer; +use crate::bottom_pane::InputResult; + +/// Action returned from feeding a key event into the ComposerInput. +pub enum ComposerAction { + /// The user submitted the current text (typically via Enter). Contains the submitted text. + Submitted(String), + /// No submission occurred; UI may need to redraw if `needs_redraw()` returned true. + None, +} + +/// A minimal, public wrapper for the internal `ChatComposer` that behaves as a +/// reusable text input field with submit semantics. +pub struct ComposerInput { + inner: ChatComposer, + _tx: tokio::sync::mpsc::UnboundedSender, + rx: tokio::sync::mpsc::UnboundedReceiver, +} + +impl ComposerInput { + /// Create a new composer input with a neutral placeholder. + pub fn new() -> Self { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let sender = AppEventSender::new(tx.clone()); + // `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior. + let inner = ChatComposer::new(true, sender, true, "Compose new task".to_string(), false); + Self { inner, _tx: tx, rx } + } + + /// Returns true if the input is empty. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Clear the input text. + pub fn clear(&mut self) { + self.inner.set_text_content(String::new()); + } + + /// Feed a key event into the composer and return a high-level action. + pub fn input(&mut self, key: KeyEvent) -> ComposerAction { + let action = match self.inner.handle_key_event(key).0 { + InputResult::Submitted(text) => ComposerAction::Submitted(text), + _ => ComposerAction::None, + }; + self.drain_app_events(); + action + } + + pub fn handle_paste(&mut self, pasted: String) -> bool { + let handled = self.inner.handle_paste(pasted); + self.drain_app_events(); + handled + } + + /// Override the footer hint items displayed under the composer. + /// Each tuple is rendered as "