diff --git a/.github/dotslash-argument-comment-lint-config.json b/.github/dotslash-argument-comment-lint-config.json new file mode 100644 index 0000000000..19a2a4803c --- /dev/null +++ b/.github/dotslash-argument-comment-lint-config.json @@ -0,0 +1,24 @@ +{ + "outputs": { + "argument-comment-lint": { + "platforms": { + "macos-aarch64": { + "regex": "^argument-comment-lint-aarch64-apple-darwin\\.tar\\.gz$", + "path": "argument-comment-lint/bin/argument-comment-lint" + }, + "linux-x86_64": { + "regex": "^argument-comment-lint-x86_64-unknown-linux-gnu\\.tar\\.gz$", + "path": "argument-comment-lint/bin/argument-comment-lint" + }, + "linux-aarch64": { + "regex": "^argument-comment-lint-aarch64-unknown-linux-gnu\\.tar\\.gz$", + "path": "argument-comment-lint/bin/argument-comment-lint" + }, + "windows-x86_64": { + "regex": "^argument-comment-lint-x86_64-pc-windows-msvc\\.zip$", + "path": "argument-comment-lint/bin/argument-comment-lint.exe" + } + } + } + } +} diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 0be65403d3..287e7e540f 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -141,8 +141,10 @@ jobs: run: working-directory: codex-rs env: - # Speed up repeated builds across CI runs by caching compiled objects (non-Windows). - USE_SCCACHE: ${{ startsWith(matrix.runner, 'windows') && 'false' || 'true' }} + # Speed up repeated builds across CI runs by caching compiled objects, except on + # arm64 macOS runners cross-targeting x86_64 where ring/cc-rs can produce + # mixed-architecture archives under sccache. + USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }} CARGO_INCREMENTAL: "0" SCCACHE_CACHE_SIZE: 10G # In rust-ci, representative release-profile checks use thin LTO for faster feedback. @@ -506,8 +508,10 @@ jobs: run: working-directory: codex-rs env: - # Speed up repeated builds across CI runs by caching compiled objects (non-Windows). - USE_SCCACHE: ${{ startsWith(matrix.runner, 'windows') && 'false' || 'true' }} + # Speed up repeated builds across CI runs by caching compiled objects, except on + # arm64 macOS runners cross-targeting x86_64 where ring/cc-rs can produce + # mixed-architecture archives under sccache. + USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }} CARGO_INCREMENTAL: "0" SCCACHE_CACHE_SIZE: 10G diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml new file mode 100644 index 0000000000..a0d12d6db4 --- /dev/null +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -0,0 +1,103 @@ +name: rust-release-argument-comment-lint + +on: + workflow_call: + inputs: + publish: + required: true + type: boolean + +jobs: + skip: + if: ${{ !inputs.publish }} + runs-on: ubuntu-latest + steps: + - run: echo "Skipping argument-comment-lint release assets for prerelease tag" + + build: + if: ${{ inputs.publish }} + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runs_on || matrix.runner }} + timeout-minutes: 60 + + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15-xlarge + target: aarch64-apple-darwin + archive_name: argument-comment-lint-aarch64-apple-darwin.tar.gz + lib_name: libargument_comment_lint@nightly-2025-09-18-aarch64-apple-darwin.dylib + runner_binary: argument-comment-lint + cargo_dylint_binary: cargo-dylint + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + archive_name: argument-comment-lint-x86_64-unknown-linux-gnu.tar.gz + lib_name: libargument_comment_lint@nightly-2025-09-18-x86_64-unknown-linux-gnu.so + runner_binary: argument-comment-lint + cargo_dylint_binary: cargo-dylint + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + archive_name: argument-comment-lint-aarch64-unknown-linux-gnu.tar.gz + lib_name: libargument_comment_lint@nightly-2025-09-18-aarch64-unknown-linux-gnu.so + runner_binary: argument-comment-lint + cargo_dylint_binary: cargo-dylint + - runner: windows-x64 + target: x86_64-pc-windows-msvc + archive_name: argument-comment-lint-x86_64-pc-windows-msvc.zip + lib_name: argument_comment_lint@nightly-2025-09-18-x86_64-pc-windows-msvc.dll + runner_binary: argument-comment-lint.exe + cargo_dylint_binary: cargo-dylint.exe + runs_on: + group: codex-runners + labels: codex-windows-x64 + + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@1.93.0 + with: + toolchain: nightly-2025-09-18 + targets: ${{ matrix.target }} + components: llvm-tools-preview, rustc-dev, rust-src + + - name: Install tooling + shell: bash + run: | + install_root="${RUNNER_TEMP}/argument-comment-lint-tools" + cargo install --locked cargo-dylint --root "$install_root" + cargo install --locked dylint-link + echo "INSTALL_ROOT=$install_root" >> "$GITHUB_ENV" + + - name: Cargo build + working-directory: tools/argument-comment-lint + shell: bash + run: cargo build --release --target ${{ matrix.target }} + + - name: Stage artifact + shell: bash + run: | + dest="dist/argument-comment-lint/${{ matrix.target }}" + mkdir -p "$dest" + package_root="${RUNNER_TEMP}/argument-comment-lint" + rm -rf "$package_root" + mkdir -p "$package_root/bin" "$package_root/lib" + + cp "tools/argument-comment-lint/target/${{ matrix.target }}/release/${{ matrix.runner_binary }}" \ + "$package_root/bin/${{ matrix.runner_binary }}" + cp "${INSTALL_ROOT}/bin/${{ matrix.cargo_dylint_binary }}" \ + "$package_root/bin/${{ matrix.cargo_dylint_binary }}" + cp "tools/argument-comment-lint/target/${{ matrix.target }}/release/${{ matrix.lib_name }}" \ + "$package_root/lib/${{ matrix.lib_name }}" + + archive_path="$dest/${{ matrix.archive_name }}" + if [[ "${{ runner.os }}" == "Windows" ]]; then + (cd "${RUNNER_TEMP}" && 7z a "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint >/dev/null) + else + (cd "${RUNNER_TEMP}" && tar -czf "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint) + fi + + - uses: actions/upload-artifact@v7 + with: + name: argument-comment-lint-${{ matrix.target }} + path: dist/argument-comment-lint/${{ matrix.target }}/* diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 12f2fc0d8d..35078cf33d 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -380,11 +380,19 @@ jobs: publish: true secrets: inherit + argument-comment-lint-release-assets: + name: argument-comment-lint release assets + needs: tag-check + uses: ./.github/workflows/rust-release-argument-comment-lint.yml + with: + publish: true + release: needs: - build - build-windows - shell-tool-mcp + - argument-comment-lint-release-assets name: release runs-on: ubuntu-latest permissions: @@ -521,6 +529,13 @@ jobs: tag: ${{ github.ref_name }} config: .github/dotslash-config.json + - uses: facebook/dotslash-publish-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag: ${{ github.ref_name }} + config: .github/dotslash-argument-comment-lint-config.json + - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dfa174a2cf..32b41c0227 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1998,10 +1998,12 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "clap", "codex-app-server-protocol", "codex-utils-absolute-path", "codex-utils-cargo-bin", + "codex-utils-pty", "futures", "pretty_assertions", "serde", @@ -2338,6 +2340,7 @@ dependencies = [ "icu_locale_core", "icu_provider", "pretty_assertions", + "quick-xml", "schemars 0.8.22", "serde", "serde_json", @@ -7264,6 +7267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", + "serde", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0cac1be8b9..edcd98fbb1 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -232,6 +232,7 @@ portable-pty = "0.9.0" predicates = "3" pretty_assertions = "1.4.1" pulldown-cmark = "0.10" +quick-xml = "0.38.4" rand = "0.9" ratatui = "0.29.0" ratatui-macros = "0.6.0" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5626d698a4..79f497d9a3 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1201,6 +1201,21 @@ ], "type": "string" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "HookRunStatus": { "enum": [ "running", @@ -2257,6 +2272,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 40e35afc47..a81bcd8ff3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7971,6 +7971,21 @@ ], "type": "string" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "HookRunStatus": { "enum": [ "running", @@ -11954,6 +11969,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/v2/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index dc5f10f01d..cbdb18a21c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4715,6 +4715,21 @@ ], "type": "string" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "HookRunStatus": { "enum": [ "running", @@ -9714,6 +9729,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index f3505efe60..3b97466202 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -266,6 +266,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -496,6 +511,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index cfb2fa9307..b77b34536c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -266,6 +266,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -496,6 +511,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 9ecf2f3996..7f4a2b1f44 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -380,6 +380,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -610,6 +625,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 9aeaed1658..44734226d5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -465,6 +465,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1103,6 +1118,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 932e0ec9af..766fe48cef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -403,6 +403,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -861,6 +876,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index c231066a7d..1ef137f9eb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -403,6 +403,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -861,6 +876,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index f120b4e919..3b7726c423 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -403,6 +403,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -861,6 +876,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index bf38037e92..ba42df4acc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -465,6 +465,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1103,6 +1118,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index b27c8ee94f..bb9dcbdd97 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -403,6 +403,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -861,6 +876,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 77d0fd94a4..ba71383208 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -465,6 +465,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1103,6 +1118,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 87932ae6a4..53806b272b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -403,6 +403,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -861,6 +876,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index b2ded079f2..3430d24e3e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -403,6 +403,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -861,6 +876,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 0a1527f4f7..40ce73e521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -380,6 +380,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -610,6 +625,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index b7accf4c21..954321c168 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -380,6 +380,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -610,6 +625,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 6653cc81df..66ce683739 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -380,6 +380,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -610,6 +625,33 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookPromptFragment.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookPromptFragment.ts new file mode 100644 index 0000000000..2c6b18acba --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookPromptFragment.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookPromptFragment = { text: string, hookRunId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 280f862a31..f1f864ae4a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -13,6 +13,7 @@ import type { CommandExecutionStatus } from "./CommandExecutionStatus"; import type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputContentItem"; import type { DynamicToolCallStatus } from "./DynamicToolCallStatus"; import type { FileUpdateChange } from "./FileUpdateChange"; +import type { HookPromptFragment } from "./HookPromptFragment"; import type { McpToolCallError } from "./McpToolCallError"; import type { McpToolCallResult } from "./McpToolCallResult"; import type { McpToolCallStatus } from "./McpToolCallStatus"; @@ -21,7 +22,7 @@ import type { PatchApplyStatus } from "./PatchApplyStatus"; import type { UserInput } from "./UserInput"; import type { WebSearchAction } from "./WebSearchAction"; -export type ThreadItem = { "type": "userMessage", id: string, content: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, +export type ThreadItem = { "type": "userMessage", id: string, content: Array, } | { "type": "hookPrompt", id: string, fragments: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, /** * The command to be executed. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 3dcf98ae3c..27cbd842f2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -126,6 +126,7 @@ export type { HookExecutionMode } from "./HookExecutionMode"; export type { HookHandlerType } from "./HookHandlerType"; export type { HookOutputEntry } from "./HookOutputEntry"; export type { HookOutputEntryKind } from "./HookOutputEntryKind"; +export type { HookPromptFragment } from "./HookPromptFragment"; export type { HookRunStatus } from "./HookRunStatus"; export type { HookRunSummary } from "./HookRunSummary"; export type { HookScope } from "./HookScope"; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 128e2a3ce5..d7482b10c3 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -18,6 +18,7 @@ use crate::protocol::v2::TurnError; use crate::protocol::v2::TurnStatus; use crate::protocol::v2::UserInput; use crate::protocol::v2::WebSearchAction; +use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::models::MessagePhase; use codex_protocol::protocol::AgentReasoningEvent; use codex_protocol::protocol::AgentReasoningRawContentEvent; @@ -184,12 +185,37 @@ impl ThreadHistoryBuilder { match item { RolloutItem::EventMsg(event) => self.handle_event(event), RolloutItem::Compacted(payload) => self.handle_compacted(payload), - RolloutItem::TurnContext(_) - | RolloutItem::SessionMeta(_) - | RolloutItem::ResponseItem(_) => {} + RolloutItem::ResponseItem(item) => self.handle_response_item(item), + RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => {} } } + fn handle_response_item(&mut self, item: &codex_protocol::models::ResponseItem) { + let codex_protocol::models::ResponseItem::Message { + role, content, id, .. + } = item + else { + return; + }; + + if role != "user" { + return; + } + + let Some(hook_prompt) = parse_hook_prompt_message(id.as_ref(), content) else { + return; + }; + + self.ensure_turn().items.push(ThreadItem::HookPrompt { + id: hook_prompt.id, + fragments: hook_prompt + .fragments + .into_iter() + .map(crate::protocol::v2::HookPromptFragment::from) + .collect(), + }); + } + fn handle_user_message(&mut self, payload: &UserMessageEvent) { // User messages should stay in explicitly opened turns. For backward // compatibility with older streams that did not open turns explicitly, @@ -281,6 +307,7 @@ impl ThreadHistoryBuilder { ); } codex_protocol::items::TurnItem::UserMessage(_) + | codex_protocol::items::TurnItem::HookPrompt(_) | codex_protocol::items::TurnItem::AgentMessage(_) | codex_protocol::items::TurnItem::Reasoning(_) | codex_protocol::items::TurnItem::WebSearch(_) @@ -301,6 +328,7 @@ impl ThreadHistoryBuilder { ); } codex_protocol::items::TurnItem::UserMessage(_) + | codex_protocol::items::TurnItem::HookPrompt(_) | codex_protocol::items::TurnItem::AgentMessage(_) | codex_protocol::items::TurnItem::Reasoning(_) | codex_protocol::items::TurnItem::WebSearch(_) @@ -1149,8 +1177,10 @@ mod tests { use crate::protocol::v2::CommandExecutionSource; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; + use codex_protocol::items::HookPromptFragment as CoreHookPromptFragment; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::items::UserMessageItem as CoreUserMessageItem; + use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::MessagePhase as CoreMessagePhase; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::parse_command::ParsedCommand; @@ -2645,4 +2675,80 @@ mod tests { }) ); } + + #[test] + fn rebuilds_hook_prompt_items_from_rollout_response_items() { + let hook_prompt = build_hook_prompt_message(&[ + CoreHookPromptFragment::from_single_hook("Retry with tests.", "hook-run-1"), + CoreHookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"), + ]) + .expect("hook prompt message"); + let items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + RolloutItem::ResponseItem(hook_prompt), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + })), + ]; + + let turns = build_turns_from_rollout_items(&items); + + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 2); + assert_eq!( + turns[0].items[1], + ThreadItem::HookPrompt { + id: turns[0].items[1].id().to_string(), + fragments: vec![ + crate::protocol::v2::HookPromptFragment { + text: "Retry with tests.".into(), + hook_run_id: "hook-run-1".into(), + }, + crate::protocol::v2::HookPromptFragment { + text: "Then summarize cleanly.".into(), + hook_run_id: "hook-run-2".into(), + }, + ], + } + ); + } + + #[test] + fn ignores_plain_user_response_items_in_rollout_replay() { + let items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::ResponseItem(codex_protocol::models::ResponseItem::Message { + id: Some("msg-1".into()), + role: "user".into(), + content: vec![codex_protocol::models::ContentItem::InputText { + text: "plain text".into(), + }], + end_turn: None, + phase: None, + }), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + })), + ]; + + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert!(turns[0].items.is_empty()); + } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 41bf18798e..1716589320 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4130,6 +4130,12 @@ pub enum ThreadItem { UserMessage { id: String, content: Vec }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] + HookPrompt { + id: String, + fragments: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] AgentMessage { id: String, text: String, @@ -4263,10 +4269,19 @@ pub enum ThreadItem { ContextCompaction { id: String }, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct HookPromptFragment { + pub text: String, + pub hook_run_id: String, +} + impl ThreadItem { pub fn id(&self) -> &str { match self { ThreadItem::UserMessage { id, .. } + | ThreadItem::HookPrompt { id, .. } | ThreadItem::AgentMessage { id, .. } | ThreadItem::Plan { id, .. } | ThreadItem::Reasoning { id, .. } @@ -4376,6 +4391,14 @@ impl From for ThreadItem { id: user.id, content: user.content.into_iter().map(UserInput::from).collect(), }, + CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt { + id: hook_prompt.id, + fragments: hook_prompt + .fragments + .into_iter() + .map(HookPromptFragment::from) + .collect(), + }, CoreTurnItem::AgentMessage(agent) => { let text = agent .content @@ -4418,6 +4441,15 @@ impl From for ThreadItem { } } +impl From for HookPromptFragment { + fn from(value: codex_protocol::items::HookPromptFragment) -> Self { + Self { + text: value.text, + hook_run_id: value.hook_run_id, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/app_server_tracing.rs b/codex-rs/app-server/src/app_server_tracing.rs index 0bb38d5fba..26fe8ca999 100644 --- a/codex-rs/app-server/src/app_server_tracing.rs +++ b/codex-rs/app-server/src/app_server_tracing.rs @@ -107,6 +107,7 @@ fn app_server_request_span_template( app_server.api_version = "v2", app_server.client_name = field::Empty, app_server.client_version = field::Empty, + turn.id = field::Empty, ) } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 780c06a52e..26e0e8bb16 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -111,6 +111,7 @@ use codex_core::sandboxing::intersect_permission_profiles; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; +use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; @@ -1484,6 +1485,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::RawResponseItem(raw_response_item_event) => { + maybe_emit_hook_prompt_item_completed( + api_version, + conversation_id, + &event_turn_id, + &raw_response_item_event.item, + &outgoing, + ) + .await; maybe_emit_raw_response_item_completed( api_version, conversation_id, @@ -1989,6 +1998,49 @@ async fn maybe_emit_raw_response_item_completed( .await; } +async fn maybe_emit_hook_prompt_item_completed( + api_version: ApiVersion, + conversation_id: ThreadId, + turn_id: &str, + item: &codex_protocol::models::ResponseItem, + outgoing: &ThreadScopedOutgoingMessageSender, +) { + let ApiVersion::V2 = api_version else { + return; + }; + + let codex_protocol::models::ResponseItem::Message { + role, content, id, .. + } = item + else { + return; + }; + + if role != "user" { + return; + } + + let Some(hook_prompt) = parse_hook_prompt_message(id.as_ref(), content) else { + return; + }; + + let notification = ItemCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id: turn_id.to_string(), + item: ThreadItem::HookPrompt { + id: hook_prompt.id, + fragments: hook_prompt + .fragments + .into_iter() + .map(codex_app_server_protocol::HookPromptFragment::from) + .collect(), + }, + }; + outgoing + .send_server_notification(ServerNotification::ItemCompleted(notification)) + .await; +} + async fn find_and_remove_turn_summary( _conversation_id: ThreadId, thread_state: &Arc>, @@ -2760,6 +2812,8 @@ mod tests { use codex_app_server_protocol::GuardianApprovalReviewStatus; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; + use codex_protocol::items::HookPromptFragment; + use codex_protocol::items::build_hook_prompt_message; use codex_protocol::mcp::CallToolResult; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; @@ -3794,4 +3848,59 @@ mod tests { assert!(rx.try_recv().is_err(), "no messages expected"); Ok(()) } + + #[tokio::test] + async fn test_hook_prompt_raw_response_emits_item_completed() -> Result<()> { + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let conversation_id = ThreadId::new(); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + conversation_id, + ); + let item = build_hook_prompt_message(&[ + HookPromptFragment::from_single_hook("Retry with tests.", "hook-run-1"), + HookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"), + ]) + .expect("hook prompt message"); + + maybe_emit_hook_prompt_item_completed( + ApiVersion::V2, + conversation_id, + "turn-1", + &item, + &outgoing, + ) + .await; + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted( + notification, + )) => { + assert_eq!(notification.thread_id, conversation_id.to_string()); + assert_eq!(notification.turn_id, "turn-1"); + assert_eq!( + notification.item, + ThreadItem::HookPrompt { + id: notification.item.id().to_string(), + fragments: vec![ + codex_app_server_protocol::HookPromptFragment { + text: "Retry with tests.".into(), + hook_run_id: "hook-run-1".into(), + }, + codex_app_server_protocol::HookPromptFragment { + text: "Then summarize cleanly.".into(), + hook_run_id: "hook-run-2".into(), + }, + ], + } + ); + } + other => bail!("unexpected message: {other:?}"), + } + assert!(rx.try_recv().is_err(), "no extra messages expected"); + Ok(()) + } } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6eff41e372..74144d9e87 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6034,6 +6034,9 @@ impl CodexMessageProcessor { match turn_id { Ok(turn_id) => { + self.outgoing + .record_request_turn_id(&request_id, &turn_id) + .await; let turn = Turn { id: turn_id.clone(), items: vec![], @@ -6109,6 +6112,9 @@ impl CodexMessageProcessor { .await; return; } + self.outgoing + .record_request_turn_id(&request_id, ¶ms.expected_turn_id) + .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { self.outgoing.send_error(request_id, error).await; return; @@ -6589,7 +6595,10 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: TurnInterruptParams, ) { - let TurnInterruptParams { thread_id, .. } = params; + let TurnInterruptParams { thread_id, turn_id } = params; + self.outgoing + .record_request_turn_id(&request_id, &turn_id) + .await; let (thread_uuid, thread) = match self.load_thread(&thread_id).await { Ok(v) => v, diff --git a/codex-rs/app-server/src/fs_api.rs b/codex-rs/app-server/src/fs_api.rs index 1d53bbe182..9baa2b1dce 100644 --- a/codex-rs/app-server/src/fs_api.rs +++ b/codex-rs/app-server/src/fs_api.rs @@ -34,7 +34,7 @@ pub(crate) struct FsApi { impl Default for FsApi { fn default() -> Self { Self { - file_system: Arc::new(Environment.get_filesystem()), + file_system: Arc::new(Environment::default().get_filesystem()), } } } diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 7d86c86e90..7ab002eedd 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -580,7 +580,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { parent_span_id: remote_parent_span_id, context: remote_trace, } = RemoteTrace::new("00000000000000000000000000000077", "0000000000000088"); - let _: TurnStartResponse = harness + let turn_start_response: TurnStartResponse = harness .request( ClientRequest::TurnStart { request_id: RequestId::Integer(3), @@ -629,6 +629,10 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { assert_eq!(server_request_span.parent_span_id, remote_parent_span_id); assert!(server_request_span.parent_span_is_remote); assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id); + assert_eq!( + span_attr(server_request_span, "turn.id"), + Some(turn_start_response.turn.id.as_str()) + ); assert_span_descends_from(&spans, core_turn_span, server_request_span); harness.shutdown().await; diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 2ab8fb04bd..67761525b3 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -75,6 +75,10 @@ impl RequestContext { pub(crate) fn span(&self) -> Span { self.span.clone() } + + fn record_turn_id(&self, turn_id: &str) { + self.span.record("turn.id", turn_id); + } } #[derive(Debug, Clone)] @@ -217,6 +221,17 @@ impl OutgoingMessageSender { .and_then(RequestContext::request_trace) } + pub(crate) async fn record_request_turn_id( + &self, + request_id: &ConnectionRequestId, + turn_id: &str, + ) { + let request_contexts = self.request_contexts.lock().await; + if let Some(request_context) = request_contexts.get(request_id) { + request_context.record_turn_id(turn_id); + } + } + async fn take_request_context( &self, request_id: &ConnectionRequestId, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 2a6faf5788..6366418573 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -13,7 +13,6 @@ use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; use codex_app_server_protocol::ClientInfo; -use codex_app_server_protocol::CollabAgentState; use codex_app_server_protocol::CollabAgentStatus; use codex_app_server_protocol::CollabAgentTool; use codex_app_server_protocol::CollabAgentToolCallStatus; @@ -1828,16 +1827,18 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() assert_eq!(prompt, Some(CHILD_PROMPT.to_string())); assert_eq!(model, Some(REQUESTED_MODEL.to_string())); assert_eq!(reasoning_effort, Some(REQUESTED_REASONING_EFFORT)); - assert_eq!( - agents_states, - HashMap::from([( - receiver_thread_id, - CollabAgentState { - status: CollabAgentStatus::PendingInit, - message: None, - }, - )]) + let agent_state = agents_states + .get(&receiver_thread_id) + .expect("spawn completion should include child agent state"); + assert!( + matches!( + agent_state.status, + CollabAgentStatus::PendingInit | CollabAgentStatus::Running + ), + "child agent should still be initializing or already running, got {:?}", + agent_state.status ); + assert_eq!(agent_state.message, None); let turn_completed = timeout(DEFAULT_READ_TIMEOUT, async { loop { @@ -2010,16 +2011,18 @@ config_file = "./custom-role.toml" assert_eq!(prompt, Some(CHILD_PROMPT.to_string())); assert_eq!(model, Some(ROLE_MODEL.to_string())); assert_eq!(reasoning_effort, Some(ROLE_REASONING_EFFORT)); - assert_eq!( - agents_states, - HashMap::from([( - receiver_thread_id, - CollabAgentState { - status: CollabAgentStatus::PendingInit, - message: None, - }, - )]) + let agent_state = agents_states + .get(&receiver_thread_id) + .expect("spawn completion should include child agent state"); + assert!( + matches!( + agent_state.status, + CollabAgentStatus::PendingInit | CollabAgentStatus::Running + ), + "child agent should still be initializing or already running, got {:?}", + agent_state.status ); + assert_eq!(agent_state.message, None); let turn_completed = timeout(DEFAULT_READ_TIMEOUT, async { loop { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b2f88d3344..056b4c4b79 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1670,6 +1670,14 @@ }, "type": "array" }, + "terminal_title": { + "default": null, + "description": "Ordered list of terminal title item identifiers.\n\nWhen set, the TUI renders the selected items into the terminal window/tab title. When unset, the TUI defaults to: `spinner` and `project`.", + "items": { + "type": "string" + }, + "type": "array" + }, "theme": { "default": null, "description": "Syntax highlighting theme name (kebab-case).\n\nWhen set, overrides automatic light/dark theme detection. Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.", @@ -1895,6 +1903,10 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, + "experimental_exec_server_url": { + "description": "Experimental / do not use. Overrides the URL used when connecting to a remote exec server.", + "type": "string" + }, "experimental_realtime_start_instructions": { "description": "Experimental / do not use. Replaces the built-in realtime start instructions inserted into developer messages when realtime becomes active.", "type": "string" diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e2721db5f6..5242c564f6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -88,6 +88,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::items::PlanItem; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; +use codex_protocol::items::build_hook_prompt_message; use codex_protocol::mcp::CallToolResult; use codex_protocol::models::BaseInstructions; use codex_protocol::models::PermissionProfile; @@ -1849,7 +1850,9 @@ impl Session { code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), - environment: Arc::new(Environment), + environment: Arc::new( + Environment::create(config.experimental_exec_server_url.clone()).await?, + ), }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), @@ -5764,13 +5767,12 @@ pub(crate) async fn run_turn( .await; } if stop_outcome.should_block { - if let Some(continuation_prompt) = stop_outcome.continuation_prompt.clone() + if let Some(hook_prompt_message) = + build_hook_prompt_message(&stop_outcome.continuation_fragments) { - let developer_message: ResponseItem = - DeveloperInstructions::new(continuation_prompt).into(); sess.record_conversation_items( &turn_context, - std::slice::from_ref(&developer_message), + std::slice::from_ref(&hook_prompt_message), ) .await; stop_hook_active = true; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f59c72d23b..5f5688669a 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2495,7 +2495,11 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { true, )); let network_approval = Arc::new(NetworkApprovalService::default()); - let environment = Arc::new(codex_exec_server::Environment); + let environment = Arc::new( + codex_exec_server::Environment::create(None) + .await + .expect("create environment"), + ); let file_watcher = Arc::new(FileWatcher::noop()); let services = SessionServices { @@ -3291,7 +3295,11 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( true, )); let network_approval = Arc::new(NetworkApprovalService::default()); - let environment = Arc::new(codex_exec_server::Environment); + let environment = Arc::new( + codex_exec_server::Environment::create(None) + .await + .expect("create environment"), + ); let file_watcher = Arc::new(FileWatcher::noop()); let services = SessionServices { diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 9439d8125e..28be574318 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -196,7 +196,7 @@ pub(crate) async fn process_compacted_history( /// - `developer` messages because remote output can include stale/duplicated /// instruction content. /// - non-user-content `user` messages (session prefix/instruction wrappers), -/// keeping only real user messages as parsed by `parse_turn_item`. +/// while preserving real user messages and persisted hook prompts. /// /// This intentionally keeps: /// - `assistant` messages (future remote compaction models may emit them) @@ -208,7 +208,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { ResponseItem::Message { role, .. } if role == "user" => { matches!( crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(_)) + Some(TurnItem::UserMessage(_) | TurnItem::HookPrompt(_)) ) } ResponseItem::Message { role, .. } if role == "assistant" => true, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ee856664dd..667d381950 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -237,6 +237,7 @@ fn config_toml_deserializes_model_availability_nux() { show_tooltips: true, alternate_screen: AltScreenMode::default(), status_line: None, + terminal_title: None, theme: None, model_availability_nux: ModelAvailabilityNuxConfig { shown_count: HashMap::from([ @@ -921,6 +922,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { show_tooltips: true, alternate_screen: AltScreenMode::Auto, status_line: None, + terminal_title: None, theme: None, model_availability_nux: ModelAvailabilityNuxConfig::default(), } @@ -4310,6 +4312,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_exec_server_url: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -4348,6 +4351,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { tool_suggest: ToolSuggestConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_terminal_title: None, tui_theme: None, otel: OtelConfig::default(), }, @@ -4451,6 +4455,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_exec_server_url: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -4489,6 +4494,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { tool_suggest: ToolSuggestConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_terminal_title: None, tui_theme: None, otel: OtelConfig::default(), }; @@ -4590,6 +4596,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_exec_server_url: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -4628,6 +4635,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { tool_suggest: ToolSuggestConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_terminal_title: None, tui_theme: None, otel: OtelConfig::default(), }; @@ -4715,6 +4723,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { model_verbosity: Some(Verbosity::High), personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_exec_server_url: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -4753,6 +4762,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { tool_suggest: ToolSuggestConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_terminal_title: None, tui_theme: None, otel: OtelConfig::default(), }; @@ -5974,6 +5984,34 @@ experimental_realtime_start_instructions = "start instructions from config" Ok(()) } +#[test] +fn experimental_exec_server_url_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_exec_server_url = "http://127.0.0.1:8080" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_exec_server_url.as_deref(), + Some("http://127.0.0.1:8080") + ); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.experimental_exec_server_url.as_deref(), + Some("http://127.0.0.1:8080") + ); + Ok(()) +} + #[test] fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 601f91b9e5..03f477ba09 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -60,7 +60,7 @@ pub enum ConfigEdit { ClearPath { segments: Vec }, } -/// Produces a config edit that sets `[tui] theme = ""`. +/// Produces a config edit that sets `[tui].theme = ""`. pub fn syntax_theme_edit(name: &str) -> ConfigEdit { ConfigEdit::SetPath { segments: vec!["tui".to_string(), "theme".to_string()], @@ -68,11 +68,12 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit { } } +/// Produces a config edit that sets `[tui].status_line` to an explicit ordered list. +/// +/// The array is written even when it is empty so "hide the status line" stays +/// distinct from "unset, so use defaults". pub fn status_line_items_edit(items: &[String]) -> ConfigEdit { - let mut array = toml_edit::Array::new(); - for item in items { - array.push(item.clone()); - } + let array = items.iter().cloned().collect::(); ConfigEdit::SetPath { segments: vec!["tui".to_string(), "status_line".to_string()], @@ -80,6 +81,19 @@ pub fn status_line_items_edit(items: &[String]) -> ConfigEdit { } } +/// Produces a config edit that sets `[tui].terminal_title` to an explicit ordered list. +/// +/// The array is written even when it is empty so "disabled title updates" stays +/// distinct from "unset, so use defaults". +pub fn terminal_title_items_edit(items: &[String]) -> ConfigEdit { + let array = items.iter().cloned().collect::(); + + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "terminal_title".to_string()], + value: TomlItem::Value(array.into()), + } +} + pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> Vec { let mut shown_count_entries: Vec<_> = shown_count.iter().collect(); shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a1f270458a..965998d114 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -359,6 +359,11 @@ pub struct Config { /// `current-dir`. pub tui_status_line: Option>, + /// Ordered list of terminal title item identifiers for the TUI. + /// + /// When unset, the TUI defaults to: `project` and `spinner`. + pub tui_terminal_title: Option>, + /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, @@ -493,6 +498,10 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, + /// Experimental / do not use. Overrides the URL used when connecting to + /// a remote exec server. + pub experimental_exec_server_url: Option, + /// Machine-local realtime audio device preferences used by realtime voice. pub realtime_audio: RealtimeAudioConfig, @@ -1393,6 +1402,10 @@ pub struct ConfigToml { /// Base URL override for the built-in `openai` model provider. pub openai_base_url: Option, + /// Experimental / do not use. Overrides the URL used when connecting to + /// a remote exec server. + pub experimental_exec_server_url: Option, + /// Machine-local realtime audio device preferences used by realtime voice. #[serde(default)] pub audio: Option, @@ -2745,6 +2758,7 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + experimental_exec_server_url: cfg.experimental_exec_server_url, realtime_audio: cfg .audio .map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig { @@ -2814,6 +2828,7 @@ impl Config { .map(|t| t.alternate_screen) .unwrap_or_default(), tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()), + tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()), tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 113dfcd2fe..3b20779cd5 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -752,6 +752,13 @@ pub struct Tui { #[serde(default)] pub status_line: Option>, + /// Ordered list of terminal title item identifiers. + /// + /// When set, the TUI renders the selected items into the terminal window/tab title. + /// When unset, the TUI defaults to: `spinner` and `project`. + #[serde(default)] + pub terminal_title: Option>, + /// Syntax highlighting theme name (kebab-case). /// /// When set, overrides automatic light/dark theme detection. diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index d09e100fe2..f990a80dce 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -177,8 +177,7 @@ impl ContextManager { /// Returns true when a tool image was replaced, false otherwise. pub(crate) fn replace_last_turn_images(&mut self, placeholder: &str) -> bool { let Some(index) = self.items.iter().rposition(|item| { - matches!(item, ResponseItem::FunctionCallOutput { .. }) - || matches!(item, ResponseItem::Message { role, .. } if role == "user") + matches!(item, ResponseItem::FunctionCallOutput { .. }) || is_user_turn_boundary(item) }) else { return false; }; @@ -200,7 +199,7 @@ impl ContextManager { } replaced } - ResponseItem::Message { role, .. } if role == "user" => false, + ResponseItem::Message { .. } => false, _ => false, } } @@ -250,11 +249,7 @@ impl ContextManager { fn get_non_last_reasoning_items_tokens(&self) -> i64 { // Get reasoning items excluding all the ones after the last user message. - let Some(last_user_index) = self - .items - .iter() - .rposition(|item| matches!(item, ResponseItem::Message { role, .. } if role == "user")) - else { + let Some(last_user_index) = self.items.iter().rposition(is_user_turn_boundary) else { return 0; }; diff --git a/codex-rs/core/src/contextual_user_message.rs b/codex-rs/core/src/contextual_user_message.rs index f7612fe8ed..4df05f0da1 100644 --- a/codex-rs/core/src/contextual_user_message.rs +++ b/codex-rs/core/src/contextual_user_message.rs @@ -1,3 +1,5 @@ +use codex_protocol::items::HookPromptItem; +use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; @@ -94,10 +96,7 @@ const CONTEXTUAL_USER_FRAGMENTS: &[ContextualUserFragmentDefinition] = &[ SUBAGENT_NOTIFICATION_FRAGMENT, ]; -pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool { - let ContentItem::InputText { text } = content_item else { - return false; - }; +fn is_standard_contextual_user_text(text: &str) -> bool { CONTEXTUAL_USER_FRAGMENTS .iter() .any(|definition| definition.matches_text(text)) @@ -118,6 +117,40 @@ pub(crate) fn is_memory_excluded_contextual_user_fragment(content_item: &Content AGENTS_MD_FRAGMENT.matches_text(text) || SKILL_FRAGMENT.matches_text(text) } +pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool { + let ContentItem::InputText { text } = content_item else { + return false; + }; + parse_hook_prompt_fragment(text).is_some() || is_standard_contextual_user_text(text) +} + +pub(crate) fn parse_visible_hook_prompt_message( + id: Option<&String>, + content: &[ContentItem], +) -> Option { + let mut fragments = Vec::new(); + + for content_item in content { + let ContentItem::InputText { text } = content_item else { + return None; + }; + if let Some(fragment) = parse_hook_prompt_fragment(text) { + fragments.push(fragment); + continue; + } + if is_standard_contextual_user_text(text) { + continue; + } + return None; + } + + if fragments.is_empty() { + return None; + } + + Some(HookPromptItem::from_fragments(id, fragments)) +} + #[cfg(test)] #[path = "contextual_user_message_tests.rs"] mod tests; diff --git a/codex-rs/core/src/contextual_user_message_tests.rs b/codex-rs/core/src/contextual_user_message_tests.rs index 1fc6de9a82..f71ca35f6e 100644 --- a/codex-rs/core/src/contextual_user_message_tests.rs +++ b/codex-rs/core/src/contextual_user_message_tests.rs @@ -1,4 +1,6 @@ use super::*; +use codex_protocol::items::HookPromptFragment; +use codex_protocol::items::build_hook_prompt_message; #[test] fn detects_environment_context_fragment() { @@ -61,3 +63,36 @@ fn classifies_memory_excluded_fragments() { ); } } + +#[test] +fn detects_hook_prompt_fragment_and_roundtrips_escaping() { + let message = build_hook_prompt_message(&[HookPromptFragment::from_single_hook( + r#"Retry with "waves" & "#, + "hook-run-1", + )]) + .expect("hook prompt message"); + + let ResponseItem::Message { content, .. } = message else { + panic!("expected hook prompt response item"); + }; + + let [content_item] = content.as_slice() else { + panic!("expected a single content item"); + }; + + assert!(is_contextual_user_fragment(content_item)); + + let ContentItem::InputText { text } = content_item else { + panic!("expected input text content item"); + }; + let parsed = + parse_visible_hook_prompt_message(None, content.as_slice()).expect("visible hook prompt"); + assert_eq!( + parsed.fragments, + vec![HookPromptFragment { + text: r#"Retry with "waves" & "#.to_string(), + hook_run_id: "hook-run-1".to_string(), + }], + ); + assert!(!text.contains(""waves" & ")); +} diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 7a9cdb3906..ad776d1424 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -19,6 +19,7 @@ use tracing::warn; use uuid::Uuid; use crate::contextual_user_message::is_contextual_user_fragment; +use crate::contextual_user_message::parse_visible_hook_prompt_message; use crate::web_search::web_search_action_detail; pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool { @@ -100,7 +101,9 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { phase, .. } => match role.as_str() { - "user" => parse_user_message(content).map(TurnItem::UserMessage), + "user" => parse_visible_hook_prompt_message(id.as_ref(), content) + .map(TurnItem::HookPrompt) + .or_else(|| parse_user_message(content).map(TurnItem::UserMessage)), "assistant" => Some(TurnItem::AgentMessage(parse_agent_message( id.as_ref(), content, diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 7a9b7076be..553550d74a 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -1,7 +1,9 @@ use super::parse_turn_item; use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::HookPromptFragment; use codex_protocol::items::TurnItem; use codex_protocol::items::WebSearchItem; +use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; @@ -208,6 +210,67 @@ fn skips_user_instructions_and_env() { } } +#[test] +fn parses_hook_prompt_message_as_distinct_turn_item() { + let item = build_hook_prompt_message(&[HookPromptFragment::from_single_hook( + "Retry with exactly the phrase meow meow meow.", + "hook-run-1", + )]) + .expect("hook prompt message"); + + let turn_item = parse_turn_item(&item).expect("expected hook prompt turn item"); + + match turn_item { + TurnItem::HookPrompt(hook_prompt) => { + assert_eq!(hook_prompt.fragments.len(), 1); + assert_eq!( + hook_prompt.fragments[0], + HookPromptFragment { + text: "Retry with exactly the phrase meow meow meow.".to_string(), + hook_run_id: "hook-run-1".to_string(), + } + ); + } + other => panic!("expected TurnItem::HookPrompt, got {other:?}"), + } +} + +#[test] +fn parses_hook_prompt_and_hides_other_contextual_fragments() { + let item = ResponseItem::Message { + id: Some("msg-1".to_string()), + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "ctx".to_string(), + }, + ContentItem::InputText { + text: + "Retry with care & joy." + .to_string(), + }, + ], + end_turn: None, + phase: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected hook prompt turn item"); + + match turn_item { + TurnItem::HookPrompt(hook_prompt) => { + assert_eq!(hook_prompt.id, "msg-1"); + assert_eq!( + hook_prompt.fragments, + vec![HookPromptFragment { + text: "Retry with care & joy.".to_string(), + hook_run_id: "hook-run-1".to_string(), + }] + ); + } + other => panic!("expected TurnItem::HookPrompt, got {other:?}"), + } +} + #[test] fn parses_agent_message() { let item = ResponseItem::Message { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index e41b90b4d3..4b53ac156f 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -112,6 +112,7 @@ impl ToolOrchestrator { let otel_tn = &tool_ctx.tool_name; let otel_ci = &tool_ctx.call_id; let otel_user = ToolDecisionSource::User; + let otel_automated_reviewer = ToolDecisionSource::AutomatedReviewer; let otel_cfg = ToolDecisionSource::Config; // 1) Approval @@ -136,8 +137,13 @@ impl ToolOrchestrator { network_approval_context: None, }; let decision = tool.start_approval_async(req, approval_ctx).await; + let otel_source = if routes_approval_to_guardian(turn_ctx) { + otel_automated_reviewer.clone() + } else { + otel_user.clone() + }; - otel.tool_decision(otel_tn, otel_ci, &decision, otel_user.clone()); + otel.tool_decision(otel_tn, otel_ci, &decision, otel_source); match decision { ReviewDecision::Denied | ReviewDecision::Abort => { @@ -286,7 +292,12 @@ impl ToolOrchestrator { }; let decision = tool.start_approval_async(req, approval_ctx).await; - otel.tool_decision(otel_tn, otel_ci, &decision, otel_user); + let otel_source = if routes_approval_to_guardian(turn_ctx) { + otel_automated_reviewer + } else { + otel_user + }; + otel.tool_decision(otel_tn, otel_ci, &decision, otel_source); match decision { ReviewDecision::Denied | ReviewDecision::Abort => { diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 793b4fedb1..0334db8b41 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -4,6 +4,7 @@ use std::path::Path; use anyhow::Context; use anyhow::Result; use codex_core::features::Feature; +use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; @@ -82,6 +83,48 @@ else: Ok(()) } +fn write_parallel_stop_hooks(home: &Path, prompts: &[&str]) -> Result<()> { + let hook_entries = prompts + .iter() + .enumerate() + .map(|(index, prompt)| { + let script_path = home.join(format!("stop_hook_{index}.py")); + let script = format!( + r#"import json +import sys + +payload = json.load(sys.stdin) +if payload["stop_hook_active"]: + print(json.dumps({{"systemMessage": "done"}})) +else: + print(json.dumps({{"decision": "block", "reason": {prompt:?}}})) +"# + ); + fs::write(&script_path, script).with_context(|| { + format!( + "write stop hook script fixture at {}", + script_path.display() + ) + })?; + Ok(serde_json::json!({ + "type": "command", + "command": format!("python3 {}", script_path.display()), + })) + }) + .collect::>>()?; + + let hooks = serde_json::json!({ + "hooks": { + "Stop": [{ + "hooks": hook_entries, + }] + } + }); + + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn write_user_prompt_submit_hook( home: &Path, blocked_prompt: &str, @@ -168,7 +211,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: Ok(()) } -fn rollout_developer_texts(text: &str) -> Result> { +fn rollout_hook_prompt_texts(text: &str) -> Result> { let mut texts = Vec::new(); for line in text.lines() { let trimmed = line.trim(); @@ -177,11 +220,13 @@ fn rollout_developer_texts(text: &str) -> Result> { } let rollout: RolloutLine = serde_json::from_str(trimmed).context("parse rollout line")?; if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = rollout.item - && role == "developer" + && role == "user" { for item in content { - if let ContentItem::InputText { text } = item { - texts.push(text); + if let ContentItem::InputText { text } = item + && let Some(fragment) = parse_hook_prompt_fragment(&text) + { + texts.push(fragment.text); } } } @@ -189,6 +234,16 @@ fn rollout_developer_texts(text: &str) -> Result> { Ok(texts) } +fn request_hook_prompt_texts( + request: &core_test_support::responses::ResponsesRequest, +) -> Vec { + request + .message_input_texts("user") + .into_iter() + .filter_map(|text| parse_hook_prompt_fragment(&text).map(|fragment| fragment.text)) + .collect() +} + fn read_stop_hook_inputs(home: &Path) -> Result> { fs::read_to_string(home.join("stop_hook_log.jsonl")) .context("read stop hook log")? @@ -298,23 +353,18 @@ async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> { let requests = responses.requests(); assert_eq!(requests.len(), 3); - assert!( - requests[1] - .message_input_texts("developer") - .contains(&FIRST_CONTINUATION_PROMPT.to_string()), - "second request should include the first continuation prompt", + assert_eq!( + request_hook_prompt_texts(&requests[1]), + vec![FIRST_CONTINUATION_PROMPT.to_string()], + "second request should include the first continuation prompt as user hook context", ); - assert!( - requests[2] - .message_input_texts("developer") - .contains(&FIRST_CONTINUATION_PROMPT.to_string()), - "third request should retain the first continuation prompt from history", - ); - assert!( - requests[2] - .message_input_texts("developer") - .contains(&SECOND_CONTINUATION_PROMPT.to_string()), - "third request should include the second continuation prompt", + assert_eq!( + request_hook_prompt_texts(&requests[2]), + vec![ + FIRST_CONTINUATION_PROMPT.to_string(), + SECOND_CONTINUATION_PROMPT.to_string(), + ], + "third request should retain hook prompts in user history", ); let hook_inputs = read_stop_hook_inputs(test.codex_home_path())?; @@ -356,13 +406,13 @@ async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> { let rollout_path = test.codex.rollout_path().expect("rollout path"); let rollout_text = fs::read_to_string(&rollout_path)?; - let developer_texts = rollout_developer_texts(&rollout_text)?; + let hook_prompt_texts = rollout_hook_prompt_texts(&rollout_text)?; assert!( - developer_texts.contains(&FIRST_CONTINUATION_PROMPT.to_string()), + hook_prompt_texts.contains(&FIRST_CONTINUATION_PROMPT.to_string()), "rollout should persist the first continuation prompt", ); assert!( - developer_texts.contains(&SECOND_CONTINUATION_PROMPT.to_string()), + hook_prompt_texts.contains(&SECOND_CONTINUATION_PROMPT.to_string()), "rollout should persist the second continuation prompt", ); @@ -481,11 +531,76 @@ async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<() resumed.submit_turn("and now continue").await?; let resumed_request = resumed_response.single_request(); - assert!( - resumed_request - .message_input_texts("developer") - .contains(&FIRST_CONTINUATION_PROMPT.to_string()), - "resumed request should keep the persisted continuation prompt in history", + assert_eq!( + request_hook_prompt_texts(&resumed_request), + vec![FIRST_CONTINUATION_PROMPT.to_string()], + "resumed request should keep the persisted continuation prompt in user history", + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multiple_blocking_stop_hooks_persist_multiple_hook_prompt_fragments() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "draft one"), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "final draft"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_parallel_stop_hooks( + home, + &[FIRST_CONTINUATION_PROMPT, SECOND_CONTINUATION_PROMPT], + ) { + panic!("failed to write parallel stop hook fixtures: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn("hello again").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + request_hook_prompt_texts(&requests[1]), + vec![ + FIRST_CONTINUATION_PROMPT.to_string(), + SECOND_CONTINUATION_PROMPT.to_string(), + ], + "second request should receive one user hook prompt message with both fragments", + ); + + let rollout_path = test.codex.rollout_path().expect("rollout path"); + let rollout_text = fs::read_to_string(&rollout_path)?; + assert_eq!( + rollout_hook_prompt_texts(&rollout_text)?, + vec![ + FIRST_CONTINUATION_PROMPT.to_string(), + SECOND_CONTINUATION_PROMPT.to_string(), + ], + "rollout should preserve both hook prompt fragments in order", ); Ok(()) diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 6395611799..860d83fe9a 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -683,6 +683,8 @@ async fn remote_models_do_not_append_removed_builtin_presets() -> Result<()> { 1, "expected a single /models request" ); + // Keep the mock server alive until after async assertions complete. + drop(server); Ok(()) } diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 91af099eb2..fac7649e49 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -16,9 +16,11 @@ workspace = true [dependencies] async-trait = { workspace = true } +base64 = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-app-server-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-pty = { workspace = true } futures = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 4b4e69f24a..a7680e73e8 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -1,20 +1,65 @@ use std::sync::Arc; use std::time::Duration; +use codex_app_server_protocol::FsCopyParams; +use codex_app_server_protocol::FsCopyResponse; +use codex_app_server_protocol::FsCreateDirectoryParams; +use codex_app_server_protocol::FsCreateDirectoryResponse; +use codex_app_server_protocol::FsGetMetadataParams; +use codex_app_server_protocol::FsGetMetadataResponse; +use codex_app_server_protocol::FsReadDirectoryParams; +use codex_app_server_protocol::FsReadDirectoryResponse; +use codex_app_server_protocol::FsReadFileParams; +use codex_app_server_protocol::FsReadFileResponse; +use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsRemoveResponse; +use codex_app_server_protocol::FsWriteFileParams; +use codex_app_server_protocol::FsWriteFileResponse; +use codex_app_server_protocol::JSONRPCNotification; +use serde_json::Value; +use tokio::sync::broadcast; +use tokio::sync::mpsc; use tokio::time::timeout; use tokio_tungstenite::connect_async; +use tracing::debug; use tracing::warn; use crate::client_api::ExecServerClientConnectOptions; +use crate::client_api::ExecServerEvent; use crate::client_api::RemoteExecServerConnectArgs; use crate::connection::JsonRpcConnection; +use crate::protocol::EXEC_EXITED_METHOD; +use crate::protocol::EXEC_METHOD; +use crate::protocol::EXEC_OUTPUT_DELTA_METHOD; +use crate::protocol::EXEC_READ_METHOD; +use crate::protocol::EXEC_TERMINATE_METHOD; +use crate::protocol::EXEC_WRITE_METHOD; +use crate::protocol::ExecExitedNotification; +use crate::protocol::ExecOutputDeltaNotification; +use crate::protocol::ExecParams; +use crate::protocol::ExecResponse; +use crate::protocol::FS_COPY_METHOD; +use crate::protocol::FS_CREATE_DIRECTORY_METHOD; +use crate::protocol::FS_GET_METADATA_METHOD; +use crate::protocol::FS_READ_DIRECTORY_METHOD; +use crate::protocol::FS_READ_FILE_METHOD; +use crate::protocol::FS_REMOVE_METHOD; +use crate::protocol::FS_WRITE_FILE_METHOD; use crate::protocol::INITIALIZE_METHOD; use crate::protocol::INITIALIZED_METHOD; use crate::protocol::InitializeParams; use crate::protocol::InitializeResponse; +use crate::protocol::ReadParams; +use crate::protocol::ReadResponse; +use crate::protocol::TerminateParams; +use crate::protocol::TerminateResponse; +use crate::protocol::WriteParams; +use crate::protocol::WriteResponse; use crate::rpc::RpcCallError; use crate::rpc::RpcClient; use crate::rpc::RpcClientEvent; +use crate::rpc::RpcNotificationSender; +use crate::rpc::RpcServerOutboundMessage; mod local_backend; use local_backend::LocalBackend; @@ -74,6 +119,7 @@ impl ClientBackend { struct Inner { backend: ClientBackend, + events_tx: broadcast::Sender, reader_task: tokio::task::JoinHandle<()>, } @@ -124,11 +170,32 @@ impl ExecServerClient { pub async fn connect_in_process( options: ExecServerClientConnectOptions, ) -> Result { - let backend = LocalBackend::new(crate::server::ExecServerHandler::new()); - let inner = Arc::new(Inner { - backend: ClientBackend::InProcess(backend), - reader_task: tokio::spawn(async {}), + let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(256); + let backend = LocalBackend::new(crate::server::ExecServerHandler::new( + RpcNotificationSender::new(outgoing_tx), + )); + let inner = Arc::new_cyclic(|weak| { + let weak = weak.clone(); + let reader_task = tokio::spawn(async move { + while let Some(message) = outgoing_rx.recv().await { + if let Some(inner) = weak.upgrade() + && let Err(err) = handle_in_process_outbound_message(&inner, message).await + { + warn!( + "in-process exec-server client closing after unexpected response: {err}" + ); + return; + } + } + }); + + Inner { + backend: ClientBackend::InProcess(backend), + events_tx: broadcast::channel(256).0, + reader_task, + } }); + let client = Self { inner }; client.initialize(options).await?; Ok(client) @@ -160,6 +227,10 @@ impl ExecServerClient { .await } + pub fn event_receiver(&self) -> broadcast::Receiver { + self.inner.events_tx.subscribe() + } + pub async fn initialize( &self, options: ExecServerClientConnectOptions, @@ -190,36 +261,234 @@ impl ExecServerClient { })? } + pub async fn exec(&self, params: ExecParams) -> Result { + if let Some(backend) = self.inner.backend.as_local() { + return backend.exec(params).await; + } + let Some(remote) = self.inner.backend.as_remote() else { + return Err(ExecServerError::Protocol( + "remote backend missing during exec".to_string(), + )); + }; + remote.call(EXEC_METHOD, ¶ms).await.map_err(Into::into) + } + + pub async fn read(&self, params: ReadParams) -> Result { + if let Some(backend) = self.inner.backend.as_local() { + return backend.exec_read(params).await; + } + let Some(remote) = self.inner.backend.as_remote() else { + return Err(ExecServerError::Protocol( + "remote backend missing during read".to_string(), + )); + }; + remote + .call(EXEC_READ_METHOD, ¶ms) + .await + .map_err(Into::into) + } + + pub async fn write( + &self, + process_id: &str, + chunk: Vec, + ) -> Result { + let params = WriteParams { + process_id: process_id.to_string(), + chunk: chunk.into(), + }; + if let Some(backend) = self.inner.backend.as_local() { + return backend.exec_write(params).await; + } + let Some(remote) = self.inner.backend.as_remote() else { + return Err(ExecServerError::Protocol( + "remote backend missing during write".to_string(), + )); + }; + remote + .call(EXEC_WRITE_METHOD, ¶ms) + .await + .map_err(Into::into) + } + + pub async fn terminate(&self, process_id: &str) -> Result { + let params = TerminateParams { + process_id: process_id.to_string(), + }; + if let Some(backend) = self.inner.backend.as_local() { + return backend.terminate(params).await; + } + let Some(remote) = self.inner.backend.as_remote() else { + return Err(ExecServerError::Protocol( + "remote backend missing during terminate".to_string(), + )); + }; + remote + .call(EXEC_TERMINATE_METHOD, ¶ms) + .await + .map_err(Into::into) + } + + pub async fn fs_read_file( + &self, + params: FsReadFileParams, + ) -> Result { + if let Some(backend) = self.inner.backend.as_local() { + return backend.fs_read_file(params).await; + } + let Some(remote) = self.inner.backend.as_remote() else { + return Err(ExecServerError::Protocol( + "remote backend missing during fs/readFile".to_string(), + )); + }; + remote + .call(FS_READ_FILE_METHOD, ¶ms) + .await + .map_err(Into::into) + } + + pub async fn fs_write_file( + &self, + params: FsWriteFileParams, + ) -> Result { + if let Some(backend) = self.inner.backend.as_local() { + return backend.fs_write_file(params).await; + } + let Some(remote) = self.inner.backend.as_remote() else { + return Err(ExecServerError::Protocol( + "remote backend missing during fs/writeFile".to_string(), + )); + }; + remote + .call(FS_WRITE_FILE_METHOD, ¶ms) + .await + .map_err(Into::into) + } + + pub async fn fs_create_directory( + &self, + params: FsCreateDirectoryParams, + ) -> Result { + if let Some(backend) = self.inner.backend.as_local() { + return backend.fs_create_directory(params).await; + } + let Some(remote) = self.inner.backend.as_remote() else { + return Err(ExecServerError::Protocol( + "remote backend missing during fs/createDirectory".to_string(), + )); + }; + remote + .call(FS_CREATE_DIRECTORY_METHOD, ¶ms) + .await + .map_err(Into::into) + } + + pub async fn fs_get_metadata( + &self, + params: FsGetMetadataParams, + ) -> Result { + if let Some(backend) = self.inner.backend.as_local() { + return backend.fs_get_metadata(params).await; + } + let Some(remote) = self.inner.backend.as_remote() else { + return Err(ExecServerError::Protocol( + "remote backend missing during fs/getMetadata".to_string(), + )); + }; + remote + .call(FS_GET_METADATA_METHOD, ¶ms) + .await + .map_err(Into::into) + } + + pub async fn fs_read_directory( + &self, + params: FsReadDirectoryParams, + ) -> Result { + if let Some(backend) = self.inner.backend.as_local() { + return backend.fs_read_directory(params).await; + } + let Some(remote) = self.inner.backend.as_remote() else { + return Err(ExecServerError::Protocol( + "remote backend missing during fs/readDirectory".to_string(), + )); + }; + remote + .call(FS_READ_DIRECTORY_METHOD, ¶ms) + .await + .map_err(Into::into) + } + + pub async fn fs_remove( + &self, + params: FsRemoveParams, + ) -> Result { + if let Some(backend) = self.inner.backend.as_local() { + return backend.fs_remove(params).await; + } + let Some(remote) = self.inner.backend.as_remote() else { + return Err(ExecServerError::Protocol( + "remote backend missing during fs/remove".to_string(), + )); + }; + remote + .call(FS_REMOVE_METHOD, ¶ms) + .await + .map_err(Into::into) + } + + pub async fn fs_copy(&self, params: FsCopyParams) -> Result { + if let Some(backend) = self.inner.backend.as_local() { + return backend.fs_copy(params).await; + } + let Some(remote) = self.inner.backend.as_remote() else { + return Err(ExecServerError::Protocol( + "remote backend missing during fs/copy".to_string(), + )); + }; + remote + .call(FS_COPY_METHOD, ¶ms) + .await + .map_err(Into::into) + } + async fn connect( connection: JsonRpcConnection, options: ExecServerClientConnectOptions, ) -> Result { let (rpc_client, mut events_rx) = RpcClient::new(connection); - let reader_task = tokio::spawn(async move { - while let Some(event) = events_rx.recv().await { - match event { - RpcClientEvent::Notification(notification) => { - warn!( - "ignoring unexpected exec-server notification during stub phase: {}", - notification.method - ); - } - RpcClientEvent::Disconnected { reason } => { - if let Some(reason) = reason { - warn!("exec-server client transport disconnected: {reason}"); + let inner = Arc::new_cyclic(|weak| { + let weak = weak.clone(); + let reader_task = tokio::spawn(async move { + while let Some(event) = events_rx.recv().await { + match event { + RpcClientEvent::Notification(notification) => { + if let Some(inner) = weak.upgrade() + && let Err(err) = + handle_server_notification(&inner, notification).await + { + warn!("exec-server client closing after protocol error: {err}"); + return; + } + } + RpcClientEvent::Disconnected { reason } => { + if let Some(reason) = reason { + warn!("exec-server client transport disconnected: {reason}"); + } + return; } - return; } } + }); + + Inner { + backend: ClientBackend::Remote(rpc_client), + events_tx: broadcast::channel(256).0, + reader_task, } }); - let client = Self { - inner: Arc::new(Inner { - backend: ClientBackend::Remote(rpc_client), - reader_task, - }), - }; + let client = Self { inner }; client.initialize(options).await?; Ok(client) } @@ -247,3 +516,39 @@ impl From for ExecServerError { } } } + +async fn handle_in_process_outbound_message( + inner: &Arc, + message: RpcServerOutboundMessage, +) -> Result<(), ExecServerError> { + match message { + RpcServerOutboundMessage::Response { .. } | RpcServerOutboundMessage::Error { .. } => Err( + ExecServerError::Protocol("unexpected in-process RPC response".to_string()), + ), + RpcServerOutboundMessage::Notification(notification) => { + handle_server_notification(inner, notification).await + } + } +} + +async fn handle_server_notification( + inner: &Arc, + notification: JSONRPCNotification, +) -> Result<(), ExecServerError> { + match notification.method.as_str() { + EXEC_OUTPUT_DELTA_METHOD => { + let params: ExecOutputDeltaNotification = + serde_json::from_value(notification.params.unwrap_or(Value::Null))?; + let _ = inner.events_tx.send(ExecServerEvent::OutputDelta(params)); + } + EXEC_EXITED_METHOD => { + let params: ExecExitedNotification = + serde_json::from_value(notification.params.unwrap_or(Value::Null))?; + let _ = inner.events_tx.send(ExecServerEvent::Exited(params)); + } + other => { + debug!("ignoring unknown exec-server notification: {other}"); + } + } + Ok(()) +} diff --git a/codex-rs/exec-server/src/client/local_backend.rs b/codex-rs/exec-server/src/client/local_backend.rs index 8f9a2481f8..e23a5361d3 100644 --- a/codex-rs/exec-server/src/client/local_backend.rs +++ b/codex-rs/exec-server/src/client/local_backend.rs @@ -1,7 +1,29 @@ use std::sync::Arc; +use crate::protocol::ExecParams; +use crate::protocol::ExecResponse; use crate::protocol::InitializeResponse; +use crate::protocol::ReadParams; +use crate::protocol::ReadResponse; +use crate::protocol::TerminateParams; +use crate::protocol::TerminateResponse; +use crate::protocol::WriteParams; +use crate::protocol::WriteResponse; use crate::server::ExecServerHandler; +use codex_app_server_protocol::FsCopyParams; +use codex_app_server_protocol::FsCopyResponse; +use codex_app_server_protocol::FsCreateDirectoryParams; +use codex_app_server_protocol::FsCreateDirectoryResponse; +use codex_app_server_protocol::FsGetMetadataParams; +use codex_app_server_protocol::FsGetMetadataResponse; +use codex_app_server_protocol::FsReadDirectoryParams; +use codex_app_server_protocol::FsReadDirectoryResponse; +use codex_app_server_protocol::FsReadFileParams; +use codex_app_server_protocol::FsReadFileResponse; +use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsRemoveResponse; +use codex_app_server_protocol::FsWriteFileParams; +use codex_app_server_protocol::FsWriteFileResponse; use super::ExecServerError; @@ -35,4 +57,144 @@ impl LocalBackend { .initialized() .map_err(ExecServerError::Protocol) } + + pub(super) async fn exec(&self, params: ExecParams) -> Result { + self.handler + .exec(params) + .await + .map_err(|error| ExecServerError::Server { + code: error.code, + message: error.message, + }) + } + + pub(super) async fn exec_read( + &self, + params: ReadParams, + ) -> Result { + self.handler + .exec_read(params) + .await + .map_err(|error| ExecServerError::Server { + code: error.code, + message: error.message, + }) + } + + pub(super) async fn exec_write( + &self, + params: WriteParams, + ) -> Result { + self.handler + .exec_write(params) + .await + .map_err(|error| ExecServerError::Server { + code: error.code, + message: error.message, + }) + } + + pub(super) async fn terminate( + &self, + params: TerminateParams, + ) -> Result { + self.handler + .terminate(params) + .await + .map_err(|error| ExecServerError::Server { + code: error.code, + message: error.message, + }) + } + + pub(super) async fn fs_read_file( + &self, + params: FsReadFileParams, + ) -> Result { + self.handler + .fs_read_file(params) + .await + .map_err(|error| ExecServerError::Server { + code: error.code, + message: error.message, + }) + } + + pub(super) async fn fs_write_file( + &self, + params: FsWriteFileParams, + ) -> Result { + self.handler + .fs_write_file(params) + .await + .map_err(|error| ExecServerError::Server { + code: error.code, + message: error.message, + }) + } + + pub(super) async fn fs_create_directory( + &self, + params: FsCreateDirectoryParams, + ) -> Result { + self.handler + .fs_create_directory(params) + .await + .map_err(|error| ExecServerError::Server { + code: error.code, + message: error.message, + }) + } + + pub(super) async fn fs_get_metadata( + &self, + params: FsGetMetadataParams, + ) -> Result { + self.handler + .fs_get_metadata(params) + .await + .map_err(|error| ExecServerError::Server { + code: error.code, + message: error.message, + }) + } + + pub(super) async fn fs_read_directory( + &self, + params: FsReadDirectoryParams, + ) -> Result { + self.handler + .fs_read_directory(params) + .await + .map_err(|error| ExecServerError::Server { + code: error.code, + message: error.message, + }) + } + + pub(super) async fn fs_remove( + &self, + params: FsRemoveParams, + ) -> Result { + self.handler + .fs_remove(params) + .await + .map_err(|error| ExecServerError::Server { + code: error.code, + message: error.message, + }) + } + + pub(super) async fn fs_copy( + &self, + params: FsCopyParams, + ) -> Result { + self.handler + .fs_copy(params) + .await + .map_err(|error| ExecServerError::Server { + code: error.code, + message: error.message, + }) + } } diff --git a/codex-rs/exec-server/src/client_api.rs b/codex-rs/exec-server/src/client_api.rs index 6e89763416..962d3ba364 100644 --- a/codex-rs/exec-server/src/client_api.rs +++ b/codex-rs/exec-server/src/client_api.rs @@ -1,5 +1,8 @@ use std::time::Duration; +use crate::protocol::ExecExitedNotification; +use crate::protocol::ExecOutputDeltaNotification; + /// Connection options for any exec-server client transport. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExecServerClientConnectOptions { @@ -15,3 +18,10 @@ pub struct RemoteExecServerConnectArgs { pub connect_timeout: Duration, pub initialize_timeout: Duration, } + +/// Connection-level server events. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExecServerEvent { + OutputDelta(ExecOutputDeltaNotification), + Exited(ExecExitedNotification), +} diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index eb8658780f..c8635ec03a 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1,11 +1,76 @@ +use crate::ExecServerClient; +use crate::ExecServerError; +use crate::RemoteExecServerConnectArgs; use crate::fs; use crate::fs::ExecutorFileSystem; -#[derive(Clone, Debug, Default)] -pub struct Environment; +#[derive(Clone, Default)] +pub struct Environment { + experimental_exec_server_url: Option, + remote_exec_server_client: Option, +} + +impl std::fmt::Debug for Environment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Environment") + .field( + "experimental_exec_server_url", + &self.experimental_exec_server_url, + ) + .field( + "has_remote_exec_server_client", + &self.remote_exec_server_client.is_some(), + ) + .finish() + } +} impl Environment { + pub async fn create( + experimental_exec_server_url: Option, + ) -> Result { + let remote_exec_server_client = + if let Some(websocket_url) = experimental_exec_server_url.as_deref() { + Some( + ExecServerClient::connect_websocket(RemoteExecServerConnectArgs::new( + websocket_url.to_string(), + "codex-core".to_string(), + )) + .await?, + ) + } else { + None + }; + + Ok(Self { + experimental_exec_server_url, + remote_exec_server_client, + }) + } + + pub fn experimental_exec_server_url(&self) -> Option<&str> { + self.experimental_exec_server_url.as_deref() + } + + pub fn remote_exec_server_client(&self) -> Option<&ExecServerClient> { + self.remote_exec_server_client.as_ref() + } + pub fn get_filesystem(&self) -> impl ExecutorFileSystem + use<> { fs::LocalFileSystem } } + +#[cfg(test)] +mod tests { + use super::Environment; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn create_without_remote_exec_server_url_does_not_connect() { + let environment = Environment::create(None).await.expect("create environment"); + + assert_eq!(environment.experimental_exec_server_url(), None); + assert!(environment.remote_exec_server_client().is_none()); + } +} diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index fdd22e163e..3c50d0ec59 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -10,7 +10,23 @@ mod server; pub use client::ExecServerClient; pub use client::ExecServerError; pub use client_api::ExecServerClientConnectOptions; +pub use client_api::ExecServerEvent; pub use client_api::RemoteExecServerConnectArgs; +pub use codex_app_server_protocol::FsCopyParams; +pub use codex_app_server_protocol::FsCopyResponse; +pub use codex_app_server_protocol::FsCreateDirectoryParams; +pub use codex_app_server_protocol::FsCreateDirectoryResponse; +pub use codex_app_server_protocol::FsGetMetadataParams; +pub use codex_app_server_protocol::FsGetMetadataResponse; +pub use codex_app_server_protocol::FsReadDirectoryEntry; +pub use codex_app_server_protocol::FsReadDirectoryParams; +pub use codex_app_server_protocol::FsReadDirectoryResponse; +pub use codex_app_server_protocol::FsReadFileParams; +pub use codex_app_server_protocol::FsReadFileResponse; +pub use codex_app_server_protocol::FsRemoveParams; +pub use codex_app_server_protocol::FsRemoveResponse; +pub use codex_app_server_protocol::FsWriteFileParams; +pub use codex_app_server_protocol::FsWriteFileResponse; pub use environment::Environment; pub use fs::CopyOptions; pub use fs::CreateDirectoryOptions; @@ -19,8 +35,19 @@ pub use fs::FileMetadata; pub use fs::FileSystemResult; pub use fs::ReadDirectoryEntry; pub use fs::RemoveOptions; +pub use protocol::ExecExitedNotification; +pub use protocol::ExecOutputDeltaNotification; +pub use protocol::ExecOutputStream; +pub use protocol::ExecParams; +pub use protocol::ExecResponse; pub use protocol::InitializeParams; pub use protocol::InitializeResponse; +pub use protocol::ReadParams; +pub use protocol::ReadResponse; +pub use protocol::TerminateParams; +pub use protocol::TerminateResponse; +pub use protocol::WriteParams; +pub use protocol::WriteResponse; pub use server::DEFAULT_LISTEN_URL; pub use server::ExecServerListenUrlParseError; pub use server::run_main; diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 165378fb5b..4429b4ca76 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -1,8 +1,41 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use serde::Deserialize; use serde::Serialize; pub const INITIALIZE_METHOD: &str = "initialize"; pub const INITIALIZED_METHOD: &str = "initialized"; +pub const EXEC_METHOD: &str = "process/start"; +pub const EXEC_READ_METHOD: &str = "process/read"; +pub const EXEC_WRITE_METHOD: &str = "process/write"; +pub const EXEC_TERMINATE_METHOD: &str = "process/terminate"; +pub const EXEC_OUTPUT_DELTA_METHOD: &str = "process/output"; +pub const EXEC_EXITED_METHOD: &str = "process/exited"; +pub const FS_READ_FILE_METHOD: &str = "fs/readFile"; +pub const FS_WRITE_FILE_METHOD: &str = "fs/writeFile"; +pub const FS_CREATE_DIRECTORY_METHOD: &str = "fs/createDirectory"; +pub const FS_GET_METADATA_METHOD: &str = "fs/getMetadata"; +pub const FS_READ_DIRECTORY_METHOD: &str = "fs/readDirectory"; +pub const FS_REMOVE_METHOD: &str = "fs/remove"; +pub const FS_COPY_METHOD: &str = "fs/copy"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ByteChunk(#[serde(with = "base64_bytes")] pub Vec); + +impl ByteChunk { + pub fn into_inner(self) -> Vec { + self.0 + } +} + +impl From> for ByteChunk { + fn from(value: Vec) -> Self { + Self(value) + } +} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -13,3 +46,121 @@ pub struct InitializeParams { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InitializeResponse {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecParams { + /// Client-chosen logical process handle scoped to this connection/session. + /// This is a protocol key, not an OS pid. + pub process_id: String, + pub argv: Vec, + pub cwd: PathBuf, + pub env: HashMap, + pub tty: bool, + pub arg0: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecResponse { + pub process_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadParams { + pub process_id: String, + pub after_seq: Option, + pub max_bytes: Option, + pub wait_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessOutputChunk { + pub seq: u64, + pub stream: ExecOutputStream, + pub chunk: ByteChunk, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadResponse { + pub chunks: Vec, + pub next_seq: u64, + pub exited: bool, + pub exit_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteParams { + pub process_id: String, + pub chunk: ByteChunk, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteResponse { + pub accepted: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminateParams { + pub process_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminateResponse { + pub running: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ExecOutputStream { + Stdout, + Stderr, + Pty, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecOutputDeltaNotification { + pub process_id: String, + pub stream: ExecOutputStream, + pub chunk: ByteChunk, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecExitedNotification { + pub process_id: String, + pub exit_code: i32, +} + +mod base64_bytes { + use super::BASE64_STANDARD; + use base64::Engine as _; + use serde::Deserialize; + use serde::Deserializer; + use serde::Serializer; + + pub fn serialize(bytes: &[u8], serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64_STANDARD.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let encoded = String::deserialize(deserializer)?; + BASE64_STANDARD + .decode(encoded) + .map_err(serde::de::Error::custom) + } +} diff --git a/codex-rs/exec-server/src/rpc.rs b/codex-rs/exec-server/src/rpc.rs index 0c8b5cdf3f..8d79883c57 100644 --- a/codex-rs/exec-server/src/rpc.rs +++ b/codex-rs/exec-server/src/rpc.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; @@ -23,6 +25,11 @@ use crate::connection::JsonRpcConnection; use crate::connection::JsonRpcConnectionEvent; type PendingRequest = oneshot::Sender>; +type BoxFuture = Pin + Send + 'static>>; +type RequestRoute = + Box, JSONRPCRequest) -> BoxFuture + Send + Sync>; +type NotificationRoute = + Box, JSONRPCNotification) -> BoxFuture> + Send + Sync>; #[derive(Debug)] pub(crate) enum RpcClientEvent { @@ -30,6 +37,139 @@ pub(crate) enum RpcClientEvent { Disconnected { reason: Option }, } +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum RpcServerOutboundMessage { + Response { + request_id: RequestId, + result: Value, + }, + Error { + request_id: RequestId, + error: JSONRPCErrorError, + }, + #[allow(dead_code)] + Notification(JSONRPCNotification), +} + +#[allow(dead_code)] +#[derive(Clone)] +pub(crate) struct RpcNotificationSender { + outgoing_tx: mpsc::Sender, +} + +impl RpcNotificationSender { + pub(crate) fn new(outgoing_tx: mpsc::Sender) -> Self { + Self { outgoing_tx } + } + + #[allow(dead_code)] + pub(crate) async fn notify( + &self, + method: &str, + params: &P, + ) -> Result<(), JSONRPCErrorError> { + let params = serde_json::to_value(params).map_err(|err| internal_error(err.to_string()))?; + self.outgoing_tx + .send(RpcServerOutboundMessage::Notification( + JSONRPCNotification { + method: method.to_string(), + params: Some(params), + }, + )) + .await + .map_err(|_| internal_error("RPC connection closed while sending notification".into())) + } +} + +pub(crate) struct RpcRouter { + request_routes: HashMap<&'static str, RequestRoute>, + notification_routes: HashMap<&'static str, NotificationRoute>, +} + +impl Default for RpcRouter { + fn default() -> Self { + Self { + request_routes: HashMap::new(), + notification_routes: HashMap::new(), + } + } +} + +impl RpcRouter +where + S: Send + Sync + 'static, +{ + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn request(&mut self, method: &'static str, handler: F) + where + P: DeserializeOwned + Send + 'static, + R: Serialize + Send + 'static, + F: Fn(Arc, P) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.request_routes.insert( + method, + Box::new(move |state, request| { + let request_id = request.id; + let params = request.params; + let response = + decode_request_params::

(params).map(|params| handler(state, params)); + Box::pin(async move { + let response = match response { + Ok(response) => response.await, + Err(error) => { + return RpcServerOutboundMessage::Error { request_id, error }; + } + }; + match response { + Ok(result) => match serde_json::to_value(result) { + Ok(result) => RpcServerOutboundMessage::Response { request_id, result }, + Err(err) => RpcServerOutboundMessage::Error { + request_id, + error: internal_error(err.to_string()), + }, + }, + Err(error) => RpcServerOutboundMessage::Error { request_id, error }, + } + }) + }), + ); + } + + pub(crate) fn notification(&mut self, method: &'static str, handler: F) + where + P: DeserializeOwned + Send + 'static, + F: Fn(Arc, P) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.notification_routes.insert( + method, + Box::new(move |state, notification| { + let params = decode_notification_params::

(notification.params) + .map(|params| handler(state, params)); + Box::pin(async move { + let handler = match params { + Ok(handler) => handler, + Err(err) => return Err(err), + }; + handler.await + }) + }), + ); + } + + pub(crate) fn request_route(&self, method: &str) -> Option<&RequestRoute> { + self.request_routes.get(method) + } + + pub(crate) fn notification_route(&self, method: &str) -> Option<&NotificationRoute> { + self.notification_routes.get(method) + } +} + pub(crate) struct RpcClient { write_tx: mpsc::Sender, pending: Arc>>, @@ -57,14 +197,8 @@ impl RpcClient { } } JsonRpcConnectionEvent::MalformedMessage { reason } => { - warn!("JSON-RPC client closing after malformed server message: {reason}"); - let _ = event_tx - .send(RpcClientEvent::Disconnected { - reason: Some(reason), - }) - .await; - drain_pending(&pending_for_reader).await; - return; + warn!("JSON-RPC client closing after malformed message: {reason}"); + break; } JsonRpcConnectionEvent::Disconnected { reason } => { let _ = event_tx.send(RpcClientEvent::Disconnected { reason }).await; @@ -177,6 +311,91 @@ pub(crate) enum RpcCallError { Server(JSONRPCErrorError), } +pub(crate) fn encode_server_message( + message: RpcServerOutboundMessage, +) -> Result { + match message { + RpcServerOutboundMessage::Response { request_id, result } => { + Ok(JSONRPCMessage::Response(JSONRPCResponse { + id: request_id, + result, + })) + } + RpcServerOutboundMessage::Error { request_id, error } => { + Ok(JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error, + })) + } + RpcServerOutboundMessage::Notification(notification) => { + Ok(JSONRPCMessage::Notification(notification)) + } + } +} + +pub(crate) fn invalid_request(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + data: None, + message, + } +} + +pub(crate) fn method_not_found(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32601, + data: None, + message, + } +} + +pub(crate) fn invalid_params(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + data: None, + message, + } +} + +pub(crate) fn internal_error(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32603, + data: None, + message, + } +} + +fn decode_request_params

(params: Option) -> Result +where + P: DeserializeOwned, +{ + decode_params(params).map_err(|err| invalid_params(err.to_string())) +} + +fn decode_notification_params

(params: Option) -> Result +where + P: DeserializeOwned, +{ + decode_params(params).map_err(|err| err.to_string()) +} + +fn decode_params

(params: Option) -> Result +where + P: DeserializeOwned, +{ + let params = params.unwrap_or(Value::Null); + match serde_json::from_value(params.clone()) { + Ok(params) => Ok(params), + Err(err) => { + if matches!(params, Value::Object(ref map) if map.is_empty()) { + serde_json::from_value(Value::Null).map_err(|_| err) + } else { + Err(err) + } + } + } +} + async fn handle_server_message( pending: &Mutex>, event_tx: &mpsc::Sender, diff --git a/codex-rs/exec-server/src/server.rs b/codex-rs/exec-server/src/server.rs index af1e929cf2..c403b029d7 100644 --- a/codex-rs/exec-server/src/server.rs +++ b/codex-rs/exec-server/src/server.rs @@ -1,6 +1,7 @@ +mod filesystem; mod handler; -mod jsonrpc; mod processor; +mod registry; mod transport; pub(crate) use handler::ExecServerHandler; diff --git a/codex-rs/exec-server/src/server/filesystem.rs b/codex-rs/exec-server/src/server/filesystem.rs new file mode 100644 index 0000000000..bc3d22a4da --- /dev/null +++ b/codex-rs/exec-server/src/server/filesystem.rs @@ -0,0 +1,170 @@ +use std::io; +use std::sync::Arc; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::FsCopyParams; +use codex_app_server_protocol::FsCopyResponse; +use codex_app_server_protocol::FsCreateDirectoryParams; +use codex_app_server_protocol::FsCreateDirectoryResponse; +use codex_app_server_protocol::FsGetMetadataParams; +use codex_app_server_protocol::FsGetMetadataResponse; +use codex_app_server_protocol::FsReadDirectoryEntry; +use codex_app_server_protocol::FsReadDirectoryParams; +use codex_app_server_protocol::FsReadDirectoryResponse; +use codex_app_server_protocol::FsReadFileParams; +use codex_app_server_protocol::FsReadFileResponse; +use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsRemoveResponse; +use codex_app_server_protocol::FsWriteFileParams; +use codex_app_server_protocol::FsWriteFileResponse; +use codex_app_server_protocol::JSONRPCErrorError; + +use crate::CopyOptions; +use crate::CreateDirectoryOptions; +use crate::Environment; +use crate::ExecutorFileSystem; +use crate::RemoveOptions; +use crate::rpc::internal_error; +use crate::rpc::invalid_request; + +#[derive(Clone)] +pub(crate) struct ExecServerFileSystem { + file_system: Arc, +} + +impl Default for ExecServerFileSystem { + fn default() -> Self { + Self { + file_system: Arc::new(Environment.get_filesystem()), + } + } +} + +impl ExecServerFileSystem { + pub(crate) async fn read_file( + &self, + params: FsReadFileParams, + ) -> Result { + let bytes = self + .file_system + .read_file(¶ms.path) + .await + .map_err(map_fs_error)?; + Ok(FsReadFileResponse { + data_base64: STANDARD.encode(bytes), + }) + } + + pub(crate) async fn write_file( + &self, + params: FsWriteFileParams, + ) -> Result { + let bytes = STANDARD.decode(params.data_base64).map_err(|err| { + invalid_request(format!( + "fs/writeFile requires valid base64 dataBase64: {err}" + )) + })?; + self.file_system + .write_file(¶ms.path, bytes) + .await + .map_err(map_fs_error)?; + Ok(FsWriteFileResponse {}) + } + + pub(crate) async fn create_directory( + &self, + params: FsCreateDirectoryParams, + ) -> Result { + self.file_system + .create_directory( + ¶ms.path, + CreateDirectoryOptions { + recursive: params.recursive.unwrap_or(true), + }, + ) + .await + .map_err(map_fs_error)?; + Ok(FsCreateDirectoryResponse {}) + } + + pub(crate) async fn get_metadata( + &self, + params: FsGetMetadataParams, + ) -> Result { + let metadata = self + .file_system + .get_metadata(¶ms.path) + .await + .map_err(map_fs_error)?; + Ok(FsGetMetadataResponse { + is_directory: metadata.is_directory, + is_file: metadata.is_file, + created_at_ms: metadata.created_at_ms, + modified_at_ms: metadata.modified_at_ms, + }) + } + + pub(crate) async fn read_directory( + &self, + params: FsReadDirectoryParams, + ) -> Result { + let entries = self + .file_system + .read_directory(¶ms.path) + .await + .map_err(map_fs_error)?; + Ok(FsReadDirectoryResponse { + entries: entries + .into_iter() + .map(|entry| FsReadDirectoryEntry { + file_name: entry.file_name, + is_directory: entry.is_directory, + is_file: entry.is_file, + }) + .collect(), + }) + } + + pub(crate) async fn remove( + &self, + params: FsRemoveParams, + ) -> Result { + self.file_system + .remove( + ¶ms.path, + RemoveOptions { + recursive: params.recursive.unwrap_or(true), + force: params.force.unwrap_or(true), + }, + ) + .await + .map_err(map_fs_error)?; + Ok(FsRemoveResponse {}) + } + + pub(crate) async fn copy( + &self, + params: FsCopyParams, + ) -> Result { + self.file_system + .copy( + ¶ms.source_path, + ¶ms.destination_path, + CopyOptions { + recursive: params.recursive, + }, + ) + .await + .map_err(map_fs_error)?; + Ok(FsCopyResponse {}) + } +} + +fn map_fs_error(err: io::Error) -> JSONRPCErrorError { + if err.kind() == io::ErrorKind::InvalidInput { + invalid_request(err.to_string()) + } else { + internal_error(err.to_string()) + } +} diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index 838e58240e..c21aeecb5c 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -1,25 +1,112 @@ +use std::collections::HashMap; +use std::collections::VecDeque; +use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; +use std::time::Duration; +use codex_app_server_protocol::FsCopyParams; +use codex_app_server_protocol::FsCopyResponse; +use codex_app_server_protocol::FsCreateDirectoryParams; +use codex_app_server_protocol::FsCreateDirectoryResponse; +use codex_app_server_protocol::FsGetMetadataParams; +use codex_app_server_protocol::FsGetMetadataResponse; +use codex_app_server_protocol::FsReadDirectoryParams; +use codex_app_server_protocol::FsReadDirectoryResponse; +use codex_app_server_protocol::FsReadFileParams; +use codex_app_server_protocol::FsReadFileResponse; +use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsRemoveResponse; +use codex_app_server_protocol::FsWriteFileParams; +use codex_app_server_protocol::FsWriteFileResponse; use codex_app_server_protocol::JSONRPCErrorError; +use codex_utils_pty::ExecCommandSession; +use codex_utils_pty::TerminalSize; +use tokio::sync::Mutex; +use tokio::sync::Notify; +use tracing::warn; +use crate::protocol::ExecExitedNotification; +use crate::protocol::ExecOutputDeltaNotification; +use crate::protocol::ExecOutputStream; +use crate::protocol::ExecParams; +use crate::protocol::ExecResponse; use crate::protocol::InitializeResponse; -use crate::server::jsonrpc::invalid_request; +use crate::protocol::ProcessOutputChunk; +use crate::protocol::ReadParams; +use crate::protocol::ReadResponse; +use crate::protocol::TerminateParams; +use crate::protocol::TerminateResponse; +use crate::protocol::WriteParams; +use crate::protocol::WriteResponse; +use crate::rpc::RpcNotificationSender; +use crate::rpc::internal_error; +use crate::rpc::invalid_params; +use crate::rpc::invalid_request; +use crate::server::filesystem::ExecServerFileSystem; + +const RETAINED_OUTPUT_BYTES_PER_PROCESS: usize = 1024 * 1024; +#[cfg(test)] +const EXITED_PROCESS_RETENTION: Duration = Duration::from_millis(25); +#[cfg(not(test))] +const EXITED_PROCESS_RETENTION: Duration = Duration::from_secs(30); + +#[derive(Clone)] +struct RetainedOutputChunk { + seq: u64, + stream: ExecOutputStream, + chunk: Vec, +} + +struct RunningProcess { + session: ExecCommandSession, + tty: bool, + output: VecDeque, + retained_bytes: usize, + next_seq: u64, + exit_code: Option, + output_notify: Arc, +} + +enum ProcessEntry { + Starting, + Running(Box), +} pub(crate) struct ExecServerHandler { + notifications: RpcNotificationSender, + file_system: ExecServerFileSystem, + processes: Arc>>, initialize_requested: AtomicBool, initialized: AtomicBool, } impl ExecServerHandler { - pub(crate) fn new() -> Self { + pub(crate) fn new(notifications: RpcNotificationSender) -> Self { Self { + notifications, + file_system: ExecServerFileSystem::default(), + processes: Arc::new(Mutex::new(HashMap::new())), initialize_requested: AtomicBool::new(false), initialized: AtomicBool::new(false), } } - pub(crate) async fn shutdown(&self) {} + pub(crate) async fn shutdown(&self) { + let remaining = { + let mut processes = self.processes.lock().await; + processes + .drain() + .filter_map(|(_, process)| match process { + ProcessEntry::Starting => None, + ProcessEntry::Running(process) => Some(process), + }) + .collect::>() + }; + for process in remaining { + process.session.terminate(); + } + } pub(crate) fn initialize(&self) -> Result { if self.initialize_requested.swap(true, Ordering::SeqCst) { @@ -37,4 +124,394 @@ impl ExecServerHandler { self.initialized.store(true, Ordering::SeqCst); Ok(()) } + + fn require_initialized_for(&self, method_family: &str) -> Result<(), JSONRPCErrorError> { + if !self.initialize_requested.load(Ordering::SeqCst) { + return Err(invalid_request(format!( + "client must call initialize before using {method_family} methods" + ))); + } + if !self.initialized.load(Ordering::SeqCst) { + return Err(invalid_request(format!( + "client must send initialized before using {method_family} methods" + ))); + } + Ok(()) + } + + pub(crate) async fn exec(&self, params: ExecParams) -> Result { + self.require_initialized_for("exec")?; + let process_id = params.process_id.clone(); + + let (program, args) = params + .argv + .split_first() + .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; + + { + let mut process_map = self.processes.lock().await; + if process_map.contains_key(&process_id) { + return Err(invalid_request(format!( + "process {process_id} already exists" + ))); + } + process_map.insert(process_id.clone(), ProcessEntry::Starting); + } + + let spawned_result = if params.tty { + codex_utils_pty::spawn_pty_process( + program, + args, + params.cwd.as_path(), + ¶ms.env, + ¶ms.arg0, + TerminalSize::default(), + ) + .await + } else { + codex_utils_pty::spawn_pipe_process_no_stdin( + program, + args, + params.cwd.as_path(), + ¶ms.env, + ¶ms.arg0, + ) + .await + }; + let spawned = match spawned_result { + Ok(spawned) => spawned, + Err(err) => { + let mut process_map = self.processes.lock().await; + if matches!(process_map.get(&process_id), Some(ProcessEntry::Starting)) { + process_map.remove(&process_id); + } + return Err(internal_error(err.to_string())); + } + }; + + let output_notify = Arc::new(Notify::new()); + { + let mut process_map = self.processes.lock().await; + process_map.insert( + process_id.clone(), + ProcessEntry::Running(Box::new(RunningProcess { + session: spawned.session, + tty: params.tty, + output: VecDeque::new(), + retained_bytes: 0, + next_seq: 1, + exit_code: None, + output_notify: Arc::clone(&output_notify), + })), + ); + } + + tokio::spawn(stream_output( + process_id.clone(), + if params.tty { + ExecOutputStream::Pty + } else { + ExecOutputStream::Stdout + }, + spawned.stdout_rx, + self.notifications.clone(), + Arc::clone(&self.processes), + Arc::clone(&output_notify), + )); + tokio::spawn(stream_output( + process_id.clone(), + if params.tty { + ExecOutputStream::Pty + } else { + ExecOutputStream::Stderr + }, + spawned.stderr_rx, + self.notifications.clone(), + Arc::clone(&self.processes), + Arc::clone(&output_notify), + )); + tokio::spawn(watch_exit( + process_id.clone(), + spawned.exit_rx, + self.notifications.clone(), + Arc::clone(&self.processes), + output_notify, + )); + + Ok(ExecResponse { process_id }) + } + + pub(crate) async fn exec_read( + &self, + params: ReadParams, + ) -> Result { + self.require_initialized_for("exec")?; + let after_seq = params.after_seq.unwrap_or(0); + let max_bytes = params.max_bytes.unwrap_or(usize::MAX); + let wait = Duration::from_millis(params.wait_ms.unwrap_or(0)); + let deadline = tokio::time::Instant::now() + wait; + + loop { + let (response, output_notify) = { + let process_map = self.processes.lock().await; + let process = process_map.get(¶ms.process_id).ok_or_else(|| { + invalid_request(format!("unknown process id {}", params.process_id)) + })?; + let ProcessEntry::Running(process) = process else { + return Err(invalid_request(format!( + "process id {} is starting", + params.process_id + ))); + }; + + let mut chunks = Vec::new(); + let mut total_bytes = 0; + let mut next_seq = process.next_seq; + for retained in process.output.iter().filter(|chunk| chunk.seq > after_seq) { + let chunk_len = retained.chunk.len(); + if !chunks.is_empty() && total_bytes + chunk_len > max_bytes { + break; + } + total_bytes += chunk_len; + chunks.push(ProcessOutputChunk { + seq: retained.seq, + stream: retained.stream, + chunk: retained.chunk.clone().into(), + }); + next_seq = retained.seq + 1; + if total_bytes >= max_bytes { + break; + } + } + + ( + ReadResponse { + chunks, + next_seq, + exited: process.exit_code.is_some(), + exit_code: process.exit_code, + }, + Arc::clone(&process.output_notify), + ) + }; + + if !response.chunks.is_empty() + || response.exited + || tokio::time::Instant::now() >= deadline + { + return Ok(response); + } + + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Ok(response); + } + let _ = tokio::time::timeout(remaining, output_notify.notified()).await; + } + } + + pub(crate) async fn exec_write( + &self, + params: WriteParams, + ) -> Result { + self.require_initialized_for("exec")?; + let writer_tx = { + let process_map = self.processes.lock().await; + let process = process_map.get(¶ms.process_id).ok_or_else(|| { + invalid_request(format!("unknown process id {}", params.process_id)) + })?; + let ProcessEntry::Running(process) = process else { + return Err(invalid_request(format!( + "process id {} is starting", + params.process_id + ))); + }; + if !process.tty { + return Err(invalid_request(format!( + "stdin is closed for process {}", + params.process_id + ))); + } + process.session.writer_sender() + }; + + writer_tx + .send(params.chunk.into_inner()) + .await + .map_err(|_| internal_error("failed to write to process stdin".to_string()))?; + + Ok(WriteResponse { accepted: true }) + } + + pub(crate) async fn terminate( + &self, + params: TerminateParams, + ) -> Result { + self.require_initialized_for("exec")?; + let running = { + let process_map = self.processes.lock().await; + match process_map.get(¶ms.process_id) { + Some(ProcessEntry::Running(process)) => { + if process.exit_code.is_some() { + return Ok(TerminateResponse { running: false }); + } + process.session.terminate(); + true + } + Some(ProcessEntry::Starting) | None => false, + } + }; + + Ok(TerminateResponse { running }) + } + + pub(crate) async fn fs_read_file( + &self, + params: FsReadFileParams, + ) -> Result { + self.require_initialized_for("filesystem")?; + self.file_system.read_file(params).await + } + + pub(crate) async fn fs_write_file( + &self, + params: FsWriteFileParams, + ) -> Result { + self.require_initialized_for("filesystem")?; + self.file_system.write_file(params).await + } + + pub(crate) async fn fs_create_directory( + &self, + params: FsCreateDirectoryParams, + ) -> Result { + self.require_initialized_for("filesystem")?; + self.file_system.create_directory(params).await + } + + pub(crate) async fn fs_get_metadata( + &self, + params: FsGetMetadataParams, + ) -> Result { + self.require_initialized_for("filesystem")?; + self.file_system.get_metadata(params).await + } + + pub(crate) async fn fs_read_directory( + &self, + params: FsReadDirectoryParams, + ) -> Result { + self.require_initialized_for("filesystem")?; + self.file_system.read_directory(params).await + } + + pub(crate) async fn fs_remove( + &self, + params: FsRemoveParams, + ) -> Result { + self.require_initialized_for("filesystem")?; + self.file_system.remove(params).await + } + + pub(crate) async fn fs_copy( + &self, + params: FsCopyParams, + ) -> Result { + self.require_initialized_for("filesystem")?; + self.file_system.copy(params).await + } } + +async fn stream_output( + process_id: String, + stream: ExecOutputStream, + mut receiver: tokio::sync::mpsc::Receiver>, + notifications: RpcNotificationSender, + processes: Arc>>, + output_notify: Arc, +) { + while let Some(chunk) = receiver.recv().await { + let notification = { + let mut processes = processes.lock().await; + let Some(entry) = processes.get_mut(&process_id) else { + break; + }; + let ProcessEntry::Running(process) = entry else { + break; + }; + let seq = process.next_seq; + process.next_seq += 1; + process.retained_bytes += chunk.len(); + process.output.push_back(RetainedOutputChunk { + seq, + stream, + chunk: chunk.clone(), + }); + while process.retained_bytes > RETAINED_OUTPUT_BYTES_PER_PROCESS { + let Some(evicted) = process.output.pop_front() else { + break; + }; + process.retained_bytes = process.retained_bytes.saturating_sub(evicted.chunk.len()); + warn!( + "retained output cap exceeded for process {process_id}; dropping oldest output" + ); + } + ExecOutputDeltaNotification { + process_id: process_id.clone(), + stream, + chunk: chunk.into(), + } + }; + output_notify.notify_waiters(); + + if notifications + .notify(crate::protocol::EXEC_OUTPUT_DELTA_METHOD, ¬ification) + .await + .is_err() + { + break; + } + } +} + +async fn watch_exit( + process_id: String, + exit_rx: tokio::sync::oneshot::Receiver, + notifications: RpcNotificationSender, + processes: Arc>>, + output_notify: Arc, +) { + let exit_code = exit_rx.await.unwrap_or(-1); + { + let mut processes = processes.lock().await; + if let Some(ProcessEntry::Running(process)) = processes.get_mut(&process_id) { + process.exit_code = Some(exit_code); + } + } + output_notify.notify_waiters(); + if notifications + .notify( + crate::protocol::EXEC_EXITED_METHOD, + &ExecExitedNotification { + process_id: process_id.clone(), + exit_code, + }, + ) + .await + .is_err() + { + return; + } + + tokio::time::sleep(EXITED_PROCESS_RETENTION).await; + let mut processes = processes.lock().await; + if matches!( + processes.get(&process_id), + Some(ProcessEntry::Running(process)) if process.exit_code == Some(exit_code) + ) { + processes.remove(&process_id); + } +} + +#[cfg(test)] +mod tests; diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs new file mode 100644 index 0000000000..5b6c9074f2 --- /dev/null +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -0,0 +1,102 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use pretty_assertions::assert_eq; +use tokio::sync::mpsc; + +use super::ExecServerHandler; +use crate::protocol::ExecParams; +use crate::protocol::InitializeResponse; +use crate::protocol::TerminateParams; +use crate::protocol::TerminateResponse; +use crate::rpc::RpcNotificationSender; + +fn exec_params(process_id: &str) -> ExecParams { + let mut env = HashMap::new(); + if let Some(path) = std::env::var_os("PATH") { + env.insert("PATH".to_string(), path.to_string_lossy().into_owned()); + } + ExecParams { + process_id: process_id.to_string(), + argv: vec![ + "bash".to_string(), + "-lc".to_string(), + "sleep 0.1".to_string(), + ], + cwd: std::env::current_dir().expect("cwd"), + env, + tty: false, + arg0: None, + } +} + +async fn initialized_handler() -> Arc { + let (outgoing_tx, _outgoing_rx) = mpsc::channel(16); + let handler = Arc::new(ExecServerHandler::new(RpcNotificationSender::new( + outgoing_tx, + ))); + assert_eq!( + handler.initialize().expect("initialize"), + InitializeResponse {} + ); + handler.initialized().expect("initialized"); + handler +} + +#[tokio::test] +async fn duplicate_process_ids_allow_only_one_successful_start() { + let handler = initialized_handler().await; + let first_handler = Arc::clone(&handler); + let second_handler = Arc::clone(&handler); + + let (first, second) = tokio::join!( + first_handler.exec(exec_params("proc-1")), + second_handler.exec(exec_params("proc-1")), + ); + + let (successes, failures): (Vec<_>, Vec<_>) = + [first, second].into_iter().partition(Result::is_ok); + assert_eq!(successes.len(), 1); + assert_eq!(failures.len(), 1); + + let error = failures + .into_iter() + .next() + .expect("one failed request") + .expect_err("expected duplicate process error"); + assert_eq!(error.code, -32600); + assert_eq!(error.message, "process proc-1 already exists"); + + tokio::time::sleep(Duration::from_millis(150)).await; + handler.shutdown().await; +} + +#[tokio::test] +async fn terminate_reports_false_after_process_exit() { + let handler = initialized_handler().await; + handler + .exec(exec_params("proc-1")) + .await + .expect("start process"); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(1); + loop { + let response = handler + .terminate(TerminateParams { + process_id: "proc-1".to_string(), + }) + .await + .expect("terminate response"); + if response == (TerminateResponse { running: false }) { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "process should have exited within 1s" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + + handler.shutdown().await; +} diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index 7a8ca40f0c..518a1a78e0 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -1,53 +1,109 @@ -use codex_app_server_protocol::JSONRPCMessage; -use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::JSONRPCRequest; -use tracing::debug; +use std::sync::Arc; -use crate::connection::JsonRpcConnection; -use crate::connection::JsonRpcConnectionEvent; -use crate::protocol::INITIALIZE_METHOD; -use crate::protocol::INITIALIZED_METHOD; -use crate::protocol::InitializeParams; -use crate::server::ExecServerHandler; -use crate::server::jsonrpc::invalid_params; -use crate::server::jsonrpc::invalid_request_message; -use crate::server::jsonrpc::method_not_found; -use crate::server::jsonrpc::response_message; +use tokio::sync::mpsc; +use tracing::debug; use tracing::warn; -pub(crate) async fn run_connection(connection: JsonRpcConnection) { - let (json_outgoing_tx, mut incoming_rx, _connection_tasks) = connection.into_parts(); - let handler = ExecServerHandler::new(); +use crate::connection::CHANNEL_CAPACITY; +use crate::connection::JsonRpcConnection; +use crate::connection::JsonRpcConnectionEvent; +use crate::rpc::RpcNotificationSender; +use crate::rpc::RpcServerOutboundMessage; +use crate::rpc::encode_server_message; +use crate::rpc::invalid_request; +use crate::rpc::method_not_found; +use crate::server::ExecServerHandler; +use crate::server::registry::build_router; - while let Some(event) = incoming_rx.recv().await { - match event { - JsonRpcConnectionEvent::Message(message) => { - let response = match handle_connection_message(&handler, message).await { - Ok(response) => response, - Err(err) => { - tracing::warn!( - "closing exec-server connection after protocol error: {err}" - ); - break; - } - }; - let Some(response) = response else { - continue; - }; - if json_outgoing_tx.send(response).await.is_err() { +pub(crate) async fn run_connection(connection: JsonRpcConnection) { + let router = Arc::new(build_router()); + let (json_outgoing_tx, mut incoming_rx, connection_tasks) = connection.into_parts(); + let (outgoing_tx, mut outgoing_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let notifications = RpcNotificationSender::new(outgoing_tx.clone()); + let handler = Arc::new(ExecServerHandler::new(notifications)); + + let outbound_task = tokio::spawn(async move { + while let Some(message) = outgoing_rx.recv().await { + let json_message = match encode_server_message(message) { + Ok(json_message) => json_message, + Err(err) => { + warn!("failed to serialize exec-server outbound message: {err}"); break; } + }; + if json_outgoing_tx.send(json_message).await.is_err() { + break; } + } + }); + + // Process inbound events sequentially to preserve initialize/initialized ordering. + while let Some(event) = incoming_rx.recv().await { + match event { JsonRpcConnectionEvent::MalformedMessage { reason } => { warn!("ignoring malformed exec-server message: {reason}"); - if json_outgoing_tx - .send(invalid_request_message(reason)) + if outgoing_tx + .send(RpcServerOutboundMessage::Error { + request_id: codex_app_server_protocol::RequestId::Integer(-1), + error: invalid_request(reason), + }) .await .is_err() { break; } } + JsonRpcConnectionEvent::Message(message) => match message { + codex_app_server_protocol::JSONRPCMessage::Request(request) => { + if let Some(route) = router.request_route(request.method.as_str()) { + let message = route(handler.clone(), request).await; + if outgoing_tx.send(message).await.is_err() { + break; + } + } else if outgoing_tx + .send(RpcServerOutboundMessage::Error { + request_id: request.id, + error: method_not_found(format!( + "exec-server stub does not implement `{}` yet", + request.method + )), + }) + .await + .is_err() + { + break; + } + } + codex_app_server_protocol::JSONRPCMessage::Notification(notification) => { + let Some(route) = router.notification_route(notification.method.as_str()) + else { + warn!( + "closing exec-server connection after unexpected notification: {}", + notification.method + ); + break; + }; + if let Err(err) = route(handler.clone(), notification).await { + warn!("closing exec-server connection after protocol error: {err}"); + break; + } + } + codex_app_server_protocol::JSONRPCMessage::Response(response) => { + warn!( + "closing exec-server connection after unexpected client response: {:?}", + response.id + ); + break; + } + codex_app_server_protocol::JSONRPCMessage::Error(error) => { + warn!( + "closing exec-server connection after unexpected client error: {:?}", + error.id + ); + break; + } + }, JsonRpcConnectionEvent::Disconnected { reason } => { if let Some(reason) = reason { debug!("exec-server connection disconnected: {reason}"); @@ -58,64 +114,10 @@ pub(crate) async fn run_connection(connection: JsonRpcConnection) { } handler.shutdown().await; -} - -pub(crate) async fn handle_connection_message( - handler: &ExecServerHandler, - message: JSONRPCMessage, -) -> Result, String> { - match message { - JSONRPCMessage::Request(request) => Ok(Some(dispatch_request(handler, request))), - JSONRPCMessage::Notification(notification) => { - handle_notification(handler, notification)?; - Ok(None) - } - JSONRPCMessage::Response(response) => Err(format!( - "unexpected client response for request id {:?}", - response.id - )), - JSONRPCMessage::Error(error) => Err(format!( - "unexpected client error for request id {:?}", - error.id - )), - } -} - -fn dispatch_request(handler: &ExecServerHandler, request: JSONRPCRequest) -> JSONRPCMessage { - let JSONRPCRequest { - id, - method, - params, - trace: _, - } = request; - - match method.as_str() { - INITIALIZE_METHOD => { - let result = serde_json::from_value::( - params.unwrap_or(serde_json::Value::Null), - ) - .map_err(|err| invalid_params(err.to_string())) - .and_then(|_params| handler.initialize()) - .and_then(|response| { - serde_json::to_value(response).map_err(|err| invalid_params(err.to_string())) - }); - response_message(id, result) - } - other => response_message( - id, - Err(method_not_found(format!( - "exec-server stub does not implement `{other}` yet" - ))), - ), - } -} - -fn handle_notification( - handler: &ExecServerHandler, - notification: JSONRPCNotification, -) -> Result<(), String> { - match notification.method.as_str() { - INITIALIZED_METHOD => handler.initialized(), - other => Err(format!("unexpected notification method: {other}")), + drop(outgoing_tx); + for task in connection_tasks { + task.abort(); + let _ = task.await; } + let _ = outbound_task.await; } diff --git a/codex-rs/exec-server/src/server/registry.rs b/codex-rs/exec-server/src/server/registry.rs new file mode 100644 index 0000000000..482e5ab610 --- /dev/null +++ b/codex-rs/exec-server/src/server/registry.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; + +use crate::protocol::EXEC_METHOD; +use crate::protocol::EXEC_READ_METHOD; +use crate::protocol::EXEC_TERMINATE_METHOD; +use crate::protocol::EXEC_WRITE_METHOD; +use crate::protocol::ExecParams; +use crate::protocol::FS_COPY_METHOD; +use crate::protocol::FS_CREATE_DIRECTORY_METHOD; +use crate::protocol::FS_GET_METADATA_METHOD; +use crate::protocol::FS_READ_DIRECTORY_METHOD; +use crate::protocol::FS_READ_FILE_METHOD; +use crate::protocol::FS_REMOVE_METHOD; +use crate::protocol::FS_WRITE_FILE_METHOD; +use crate::protocol::INITIALIZE_METHOD; +use crate::protocol::INITIALIZED_METHOD; +use crate::protocol::InitializeParams; +use crate::protocol::ReadParams; +use crate::protocol::TerminateParams; +use crate::protocol::WriteParams; +use crate::rpc::RpcRouter; +use crate::server::ExecServerHandler; +use codex_app_server_protocol::FsCopyParams; +use codex_app_server_protocol::FsCreateDirectoryParams; +use codex_app_server_protocol::FsGetMetadataParams; +use codex_app_server_protocol::FsReadDirectoryParams; +use codex_app_server_protocol::FsReadFileParams; +use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsWriteFileParams; + +pub(crate) fn build_router() -> RpcRouter { + let mut router = RpcRouter::new(); + router.request( + INITIALIZE_METHOD, + |handler: Arc, _params: InitializeParams| async move { + handler.initialize() + }, + ); + router.notification( + INITIALIZED_METHOD, + |handler: Arc, _params: serde_json::Value| async move { + handler.initialized() + }, + ); + router.request( + EXEC_METHOD, + |handler: Arc, params: ExecParams| async move { handler.exec(params).await }, + ); + router.request( + EXEC_READ_METHOD, + |handler: Arc, params: ReadParams| async move { + handler.exec_read(params).await + }, + ); + router.request( + EXEC_WRITE_METHOD, + |handler: Arc, params: WriteParams| async move { + handler.exec_write(params).await + }, + ); + router.request( + EXEC_TERMINATE_METHOD, + |handler: Arc, params: TerminateParams| async move { + handler.terminate(params).await + }, + ); + router.request( + FS_READ_FILE_METHOD, + |handler: Arc, params: FsReadFileParams| async move { + handler.fs_read_file(params).await + }, + ); + router.request( + FS_WRITE_FILE_METHOD, + |handler: Arc, params: FsWriteFileParams| async move { + handler.fs_write_file(params).await + }, + ); + router.request( + FS_CREATE_DIRECTORY_METHOD, + |handler: Arc, params: FsCreateDirectoryParams| async move { + handler.fs_create_directory(params).await + }, + ); + router.request( + FS_GET_METADATA_METHOD, + |handler: Arc, params: FsGetMetadataParams| async move { + handler.fs_get_metadata(params).await + }, + ); + router.request( + FS_READ_DIRECTORY_METHOD, + |handler: Arc, params: FsReadDirectoryParams| async move { + handler.fs_read_directory(params).await + }, + ); + router.request( + FS_REMOVE_METHOD, + |handler: Arc, params: FsRemoveParams| async move { + handler.fs_remove(params).await + }, + ); + router.request( + FS_COPY_METHOD, + |handler: Arc, params: FsCopyParams| async move { + handler.fs_copy(params).await + }, + ); + router +} diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index a99a889ed9..4926e60882 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -2,15 +2,15 @@ mod common; -use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; +use codex_exec_server::ExecResponse; use codex_exec_server::InitializeParams; use common::exec_server::exec_server; use pretty_assertions::assert_eq; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn exec_server_stubs_process_start_over_websocket() -> anyhow::Result<()> { +async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { let mut server = exec_server().await?; let initialize_id = server .send_request( @@ -29,6 +29,10 @@ async fn exec_server_stubs_process_start_over_websocket() -> anyhow::Result<()> }) .await?; + server + .send_notification("initialized", serde_json::json!({})) + .await?; + let process_start_id = server .send_request( "process/start", @@ -46,18 +50,20 @@ async fn exec_server_stubs_process_start_over_websocket() -> anyhow::Result<()> .wait_for_event(|event| { matches!( event, - JSONRPCMessage::Error(JSONRPCError { id, .. }) if id == &process_start_id + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &process_start_id ) }) .await?; - let JSONRPCMessage::Error(JSONRPCError { id, error }) = response else { - panic!("expected process/start stub error"); + let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = response else { + panic!("expected process/start response"); }; assert_eq!(id, process_start_id); - assert_eq!(error.code, -32601); + let process_start_response: ExecResponse = serde_json::from_value(result)?; assert_eq!( - error.message, - "exec-server stub does not implement `process/start` yet" + process_start_response, + ExecResponse { + process_id: "proc-1".to_string() + } ); server.shutdown().await?; diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 837f287afb..3d94e321c0 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use codex_protocol::ThreadId; +use codex_protocol::items::HookPromptFragment; use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookOutputEntry; @@ -36,7 +37,7 @@ pub struct StopOutcome { pub stop_reason: Option, pub should_block: bool, pub block_reason: Option, - pub continuation_prompt: Option, + pub continuation_fragments: Vec, } #[derive(Debug, Default, PartialEq, Eq)] @@ -45,7 +46,7 @@ struct StopHandlerData { stop_reason: Option, should_block: bool, block_reason: Option, - continuation_prompt: Option, + continuation_fragments: Vec, } pub(crate) fn preview( @@ -72,7 +73,7 @@ pub(crate) async fn run( stop_reason: None, should_block: false, block_reason: None, - continuation_prompt: None, + continuation_fragments: Vec::new(), }; } @@ -115,7 +116,7 @@ pub(crate) async fn run( stop_reason: aggregate.stop_reason, should_block: aggregate.should_block, block_reason: aggregate.block_reason, - continuation_prompt: aggregate.continuation_prompt, + continuation_fragments: aggregate.continuation_fragments, } } @@ -239,6 +240,14 @@ fn parse_completed( turn_id, run: dispatcher::completed_summary(handler, &run_result, status, entries), }; + let continuation_fragments = continuation_prompt + .map(|prompt| { + vec![HookPromptFragment::from_single_hook( + prompt, + completed.run.id.clone(), + )] + }) + .unwrap_or_default(); dispatcher::ParsedHandler { completed, @@ -247,7 +256,7 @@ fn parse_completed( stop_reason, should_block, block_reason, - continuation_prompt, + continuation_fragments, }, } } @@ -269,15 +278,14 @@ fn aggregate_results<'a>( } else { None }; - let continuation_prompt = if should_block { - common::join_text_chunks( - results - .iter() - .filter_map(|result| result.continuation_prompt.clone()) - .collect(), - ) + let continuation_fragments = if should_block { + results + .iter() + .filter(|result| result.should_block) + .flat_map(|result| result.continuation_fragments.clone()) + .collect() } else { - None + Vec::new() }; StopHandlerData { @@ -285,7 +293,7 @@ fn aggregate_results<'a>( stop_reason, should_block, block_reason, - continuation_prompt, + continuation_fragments, } } @@ -296,7 +304,7 @@ fn serialization_failure_outcome(hook_events: Vec) -> StopOu stop_reason: None, should_block: false, block_reason: None, - continuation_prompt: None, + continuation_fragments: Vec::new(), } } @@ -310,6 +318,8 @@ mod tests { use codex_protocol::protocol::HookRunStatus; use pretty_assertions::assert_eq; + use codex_protocol::items::HookPromptFragment; + use super::StopHandlerData; use super::aggregate_results; use super::parse_completed; @@ -335,7 +345,10 @@ mod tests { stop_reason: None, should_block: true, block_reason: Some("retry with tests".to_string()), - continuation_prompt: Some("retry with tests".to_string()), + continuation_fragments: vec![HookPromptFragment { + text: "retry with tests".to_string(), + hook_run_id: parsed.completed.run.id.clone(), + }], } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); @@ -379,7 +392,7 @@ mod tests { stop_reason: Some("done".to_string()), should_block: false, block_reason: None, - continuation_prompt: None, + continuation_fragments: Vec::new(), } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped); @@ -400,7 +413,10 @@ mod tests { stop_reason: None, should_block: true, block_reason: Some("retry with tests".to_string()), - continuation_prompt: Some("retry with tests".to_string()), + continuation_fragments: vec![HookPromptFragment { + text: "retry with tests".to_string(), + hook_run_id: parsed.completed.run.id.clone(), + }], } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); @@ -469,14 +485,18 @@ mod tests { stop_reason: None, should_block: true, block_reason: Some("first".to_string()), - continuation_prompt: Some("first".to_string()), + continuation_fragments: vec![HookPromptFragment::from_single_hook( + "first", "run-1", + )], }, &StopHandlerData { should_stop: false, stop_reason: None, should_block: true, block_reason: Some("second".to_string()), - continuation_prompt: Some("second".to_string()), + continuation_fragments: vec![HookPromptFragment::from_single_hook( + "second", "run-2", + )], }, ]); @@ -487,7 +507,10 @@ mod tests { stop_reason: None, should_block: true, block_reason: Some("first\n\nsecond".to_string()), - continuation_prompt: Some("first\n\nsecond".to_string()), + continuation_fragments: vec![ + HookPromptFragment::from_single_hook("first", "run-1"), + HookPromptFragment::from_single_hook("second", "run-2"), + ], } ); } diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index 353130976c..4eb27a56e4 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -31,6 +31,7 @@ pub use codex_utils_string::sanitize_metric_tag_value; #[derive(Debug, Clone, Serialize, Display)] #[serde(rename_all = "snake_case")] pub enum ToolDecisionSource { + AutomatedReviewer, Config, User, } diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index b0f1994667..11efa9d376 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -19,6 +19,7 @@ codex-utils-image = { workspace = true } icu_decimal = { workspace = true } icu_locale_core = { workspace = true } icu_provider = { workspace = true, features = ["sync"] } +quick-xml = { workspace = true, features = ["serialize"] } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 08e50b9546..36c8cdbae2 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -1,5 +1,7 @@ use crate::memory_citation::MemoryCitation; +use crate::models::ContentItem; use crate::models::MessagePhase; +use crate::models::ResponseItem; use crate::models::WebSearchAction; use crate::protocol::AgentMessageEvent; use crate::protocol::AgentReasoningEvent; @@ -12,6 +14,8 @@ use crate::protocol::WebSearchEndEvent; use crate::user_input::ByteRange; use crate::user_input::TextElement; use crate::user_input::UserInput; +use quick_xml::de::from_str as from_xml_str; +use quick_xml::se::to_string as to_xml_string; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -22,6 +26,7 @@ use ts_rs::TS; #[ts(tag = "type")] pub enum TurnItem { UserMessage(UserMessageItem), + HookPrompt(HookPromptItem), AgentMessage(AgentMessageItem), Plan(PlanItem), Reasoning(ReasoningItem), @@ -36,6 +41,29 @@ pub struct UserMessageItem { pub content: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] +pub struct HookPromptItem { + pub id: String, + pub fragments: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct HookPromptFragment { + pub text: String, + pub hook_run_id: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename = "hook_prompt")] +struct HookPromptXml { + #[serde(rename = "@hook_run_id")] + hook_run_id: String, + #[serde(rename = "$text")] + text: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] #[serde(tag = "type")] #[ts(tag = "type")] @@ -199,6 +227,91 @@ impl UserMessageItem { } } +impl HookPromptItem { + pub fn from_fragments(id: Option<&String>, fragments: Vec) -> Self { + Self { + id: id + .cloned() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + fragments, + } + } +} + +impl HookPromptFragment { + pub fn from_single_hook(text: impl Into, hook_run_id: impl Into) -> Self { + Self { + text: text.into(), + hook_run_id: hook_run_id.into(), + } + } +} + +pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option { + let content = fragments + .iter() + .filter(|fragment| !fragment.hook_run_id.trim().is_empty()) + .filter_map(|fragment| { + serialize_hook_prompt_fragment(&fragment.text, &fragment.hook_run_id) + .map(|text| ContentItem::InputText { text }) + }) + .collect::>(); + + if content.is_empty() { + return None; + } + + Some(ResponseItem::Message { + id: Some(uuid::Uuid::new_v4().to_string()), + role: "user".to_string(), + content, + end_turn: None, + phase: None, + }) +} + +pub fn parse_hook_prompt_message( + id: Option<&String>, + content: &[ContentItem], +) -> Option { + let fragments = content + .iter() + .map(|content_item| { + let ContentItem::InputText { text } = content_item else { + return None; + }; + parse_hook_prompt_fragment(text) + }) + .collect::>>()?; + + if fragments.is_empty() { + return None; + } + + Some(HookPromptItem::from_fragments(id, fragments)) +} + +pub fn parse_hook_prompt_fragment(text: &str) -> Option { + let trimmed = text.trim(); + let HookPromptXml { text, hook_run_id } = from_xml_str::(trimmed).ok()?; + if hook_run_id.trim().is_empty() { + return None; + } + + Some(HookPromptFragment { text, hook_run_id }) +} + +fn serialize_hook_prompt_fragment(text: &str, hook_run_id: &str) -> Option { + if hook_run_id.trim().is_empty() { + return None; + } + to_xml_string(&HookPromptXml { + text: text.to_string(), + hook_run_id: hook_run_id.to_string(), + }) + .ok() +} + impl AgentMessageItem { pub fn new(content: &[AgentMessageContent]) -> Self { Self { @@ -272,6 +385,7 @@ impl TurnItem { pub fn id(&self) -> String { match self { TurnItem::UserMessage(item) => item.id.clone(), + TurnItem::HookPrompt(item) => item.id.clone(), TurnItem::AgentMessage(item) => item.id.clone(), TurnItem::Plan(item) => item.id.clone(), TurnItem::Reasoning(item) => item.id.clone(), @@ -284,6 +398,7 @@ impl TurnItem { pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { match self { TurnItem::UserMessage(item) => vec![item.as_legacy_event()], + TurnItem::HookPrompt(_) => Vec::new(), TurnItem::AgentMessage(item) => item.as_legacy_events(), TurnItem::Plan(_) => Vec::new(), TurnItem::WebSearch(item) => vec![item.as_legacy_event()], @@ -293,3 +408,41 @@ impl TurnItem { } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn hook_prompt_roundtrips_multiple_fragments() { + let original = vec![ + HookPromptFragment::from_single_hook("Retry with care & joy.", "hook-run-1"), + HookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"), + ]; + let message = build_hook_prompt_message(&original).expect("hook prompt"); + + let ResponseItem::Message { content, .. } = message else { + panic!("expected hook prompt message"); + }; + + let parsed = parse_hook_prompt_message(None, &content).expect("parsed hook prompt"); + assert_eq!(parsed.fragments, original); + } + + #[test] + fn hook_prompt_parses_legacy_single_hook_run_id() { + let parsed = parse_hook_prompt_fragment( + r#"Retry with tests."#, + ) + .expect("legacy hook prompt"); + + assert_eq!( + parsed, + HookPromptFragment { + text: "Retry with tests.".to_string(), + hook_run_id: "hook-run-1".to_string(), + } + ); + } +} diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index b898403b25..55a3603ed7 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -723,7 +723,7 @@ impl RmcpClient { None => None, }; let rmcp_params = CallToolRequestParams { - meta, + meta: None, name: name.into(), arguments, task: None, @@ -731,7 +731,30 @@ impl RmcpClient { let result = self .run_service_operation("tools/call", timeout, move |service| { let rmcp_params = rmcp_params.clone(); - async move { service.call_tool(rmcp_params).await }.boxed() + let meta = meta.clone(); + async move { + let result = service + .peer() + .send_request_with_option( + ClientRequest::CallToolRequest(rmcp::model::CallToolRequest { + method: Default::default(), + params: rmcp_params, + extensions: Default::default(), + }), + rmcp::service::PeerRequestOptions { + timeout: None, + meta, + }, + ) + .await? + .await_response() + .await?; + match result { + ServerResult::CallToolResult(result) => Ok(result), + _ => Err(rmcp::service::ServiceError::UnexpectedResponse), + } + } + .boxed() }) .await?; self.persist_oauth_tokens().await; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8995b495db..09776f0f77 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -724,6 +724,8 @@ pub(crate) struct App { pub(crate) commit_anim_running: Arc, // Shared across ChatWidget instances so invalid status-line config warnings only emit once. status_line_invalid_items_warned: Arc, + // Shared across ChatWidget instances so invalid terminal-title config warnings only emit once. + terminal_title_invalid_items_warned: Arc, // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, @@ -811,6 +813,7 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), + terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), } } @@ -1783,8 +1786,7 @@ impl App { let (tx, _rx) = unbounded_channel(); tx }; - self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx); - self.sync_active_agent_label(); + self.replace_chat_widget(ChatWidget::new_with_op_sender(init, codex_op_tx)); self.reset_for_thread_switch(tui)?; self.replay_thread_snapshot(snapshot, !is_replay_only); @@ -1824,6 +1826,16 @@ impl App { self.sync_active_agent_label(); } + fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { + let previous_terminal_title = self.chat_widget.last_terminal_title.take(); + if chat_widget.last_terminal_title.is_none() { + chat_widget.last_terminal_title = previous_terminal_title; + } + self.chat_widget = chat_widget; + self.sync_active_agent_label(); + self.refresh_status_surfaces(); + } + async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) { // Start a fresh in-memory session while preserving resumability via persisted rollout // history. @@ -1864,8 +1876,9 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), + terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), }; - self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.replace_chat_widget(ChatWidget::new(init, self.server.clone())); self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -1956,7 +1969,7 @@ impl App { if resume_restored_queue { self.chat_widget.maybe_send_next_queued_input(); } - self.refresh_status_line(); + self.refresh_status_surfaces(); } fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool { @@ -2078,6 +2091,7 @@ impl App { } let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); + let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false)); let enhanced_keys_supported = tui.enhanced_keys_supported(); let wait_for_initial_session_configured = @@ -2107,6 +2121,8 @@ impl App { startup_tooltip_override, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), }; ChatWidget::new(init, thread_manager.clone()) } @@ -2143,6 +2159,8 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), }; ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) } @@ -2185,6 +2203,8 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), }; ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) } @@ -2217,6 +2237,7 @@ impl App { has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: feedback.clone(), @@ -2386,7 +2407,7 @@ impl App { if matches!(event, TuiEvent::Draw) { let size = tui.terminal.size()?; if size != tui.terminal.last_known_screen_size { - self.refresh_status_line(); + self.refresh_status_surfaces(); } } @@ -2514,11 +2535,11 @@ impl App { tui, self.config.clone(), ); - self.chat_widget = ChatWidget::new_from_existing( + self.replace_chat_widget(ChatWidget::new_from_existing( init, resumed.thread, resumed.session_configured, - ); + )); self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = @@ -2585,11 +2606,11 @@ impl App { tui, self.config.clone(), ); - self.chat_widget = ChatWidget::new_from_existing( + self.replace_chat_widget(ChatWidget::new_from_existing( init, forked.thread, forked.session_configured, - ); + )); self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = @@ -2762,15 +2783,15 @@ impl App { } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::UpdateCollaborationMode(mask) => { self.chat_widget.set_collaboration_mask(mask); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::UpdatePersonality(personality) => { self.on_update_personality(personality); @@ -3211,7 +3232,7 @@ impl App { } } AppEvent::PersistServiceTierSelection { service_tier } => { - self.refresh_status_line(); + self.refresh_status_surfaces(); let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) @@ -3416,7 +3437,7 @@ impl App { AppEvent::UpdatePlanModeReasoningEffort(effort) => { self.config.plan_mode_reasoning_effort = effort; self.chat_widget.set_plan_mode_reasoning_effort(effort); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::PersistFullAccessWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) @@ -3730,11 +3751,38 @@ impl App { } AppEvent::StatusLineBranchUpdated { cwd, branch } => { self.chat_widget.set_status_line_branch(cwd, branch); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); } + AppEvent::TerminalTitleSetup { items } => { + let ids = items.iter().map(ToString::to_string).collect::>(); + let edit = codex_core::config::edit::terminal_title_items_edit(&ids); + let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await; + match apply_result { + Ok(()) => { + self.config.tui_terminal_title = Some(ids.clone()); + self.chat_widget.setup_terminal_title(items); + } + Err(err) => { + tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection"); + self.chat_widget.revert_terminal_title_setup_preview(); + self.chat_widget.add_error_message(format!( + "Failed to save terminal title items: {err}" + )); + } + } + } + AppEvent::TerminalTitleSetupPreview { items } => { + self.chat_widget.preview_terminal_title(items); + } + AppEvent::TerminalTitleSetupCancelled => { + self.chat_widget.cancel_terminal_title_setup(); + } AppEvent::SyntaxThemeSelected { name } => { let edit = codex_core::config::edit::syntax_theme_edit(&name); let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) @@ -3809,7 +3857,7 @@ impl App { self.chat_widget.handle_codex_event(event); if needs_refresh { - self.refresh_status_line(); + self.refresh_status_surfaces(); } } @@ -4190,8 +4238,8 @@ impl App { }; } - fn refresh_status_line(&mut self) { - self.chat_widget.refresh_status_line(); + fn refresh_status_surfaces(&mut self) { + self.chat_widget.refresh_status_surfaces(); } #[cfg(target_os = "windows")] @@ -4223,12 +4271,21 @@ impl App { } } +impl Drop for App { + fn drop(&mut self) { + if let Err(err) = self.chat_widget.clear_managed_terminal_title() { + tracing::debug!(error = %err, "failed to clear terminal title on app drop"); + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; + use crate::bottom_pane::TerminalTitleItem; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::set_chatgpt_auth; use crate::file_search::FileSearchManager; @@ -5051,6 +5108,38 @@ mod tests { } } + #[tokio::test] + async fn replace_chat_widget_preserves_terminal_title_cache_for_empty_replacement_title() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.chat_widget.last_terminal_title = Some("my-project | Ready".to_string()); + + let (mut replacement, _app_event_tx, _rx, _new_op_rx) = + make_chatwidget_manual_with_sender().await; + replacement.setup_terminal_title(Vec::new()); + + app.replace_chat_widget(replacement); + + assert_eq!(app.chat_widget.last_terminal_title, None); + } + + #[tokio::test] + async fn replace_chat_widget_keeps_replacement_terminal_title_cache_when_present() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.chat_widget.last_terminal_title = Some("old-project | Ready".to_string()); + + let (mut replacement, _app_event_tx, _rx, _new_op_rx) = + make_chatwidget_manual_with_sender().await; + replacement.setup_terminal_title(vec![TerminalTitleItem::AppName]); + replacement.last_terminal_title = Some("codex".to_string()); + + app.replace_chat_widget(replacement); + + assert_eq!( + app.chat_widget.last_terminal_title, + Some("codex".to_string()) + ); + } + #[tokio::test] async fn replay_thread_snapshot_restores_pending_pastes_for_submit() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -6472,6 +6561,7 @@ guardian_approval = true enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), @@ -6532,6 +6622,7 @@ guardian_approval = true enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index e2ed046690..d8a71c3daf 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -20,6 +20,7 @@ use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; +use crate::bottom_pane::TerminalTitleItem; use crate::history_cell::HistoryCell; use codex_core::config::types::ApprovalsReviewer; @@ -451,6 +452,16 @@ pub(crate) enum AppEvent { }, /// Dismiss the status-line setup UI without changing config. StatusLineSetupCancelled, + /// Apply a user-confirmed terminal-title item ordering/selection. + TerminalTitleSetup { + items: Vec, + }, + /// Apply a temporary terminal-title preview while the setup UI is open. + TerminalTitleSetupPreview { + items: Vec, + }, + /// Dismiss the terminal-title setup UI without changing config. + TerminalTitleSetupCancelled, /// Apply a user-confirmed syntax theme selection. SyntaxThemeSelected { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index de85b9ffe7..80f35d5fff 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -49,6 +49,7 @@ mod request_user_input; mod status_line_setup; pub(crate) use app_link_view::AppLinkElicitationTarget; pub(crate) use app_link_view::AppLinkSuggestionType; +mod title_setup; pub(crate) use app_link_view::AppLinkView; pub(crate) use app_link_view::AppLinkViewParams; pub(crate) use approval_overlay::ApprovalOverlay; @@ -100,6 +101,8 @@ pub(crate) use skills_toggle_view::SkillsToggleView; pub(crate) use status_line_setup::StatusLineItem; pub(crate) use status_line_setup::StatusLinePreviewData; pub(crate) use status_line_setup::StatusLineSetupView; +pub(crate) use title_setup::TerminalTitleItem; +pub(crate) use title_setup::TerminalTitleSetupView; mod paste_burst; mod pending_input_preview; mod pending_thread_approvals; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap new file mode 100644 index 0000000000..9a6d412874 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/title_setup.rs +expression: "render_lines(&view, 84)" +--- + + Configure Terminal Title + Select which items to display in the terminal title. + + Type to search + > +› [x] project Project name (falls back to current directory name) + [x] spinner Animated task spinner (omitted while idle or when animations… + [x] status Compact session status text (Ready, Working, Thinking) + [x] thread Current thread title (omitted until available) + [ ] app-name Codex app name + [ ] git-branch Current Git branch (omitted when unavailable) + [ ] model Current model name + [ ] task-progress Latest task progress from update_plan (omitted until availab… + + my-project ⠋ Working | Investigate flaky test + Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs new file mode 100644 index 0000000000..f15e8af71a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -0,0 +1,298 @@ +//! Terminal title configuration view for customizing the terminal window/tab title. +//! +//! This module provides an interactive picker for selecting which items appear +//! in the terminal title. Users can: +//! +//! - Select items +//! - Reorder items +//! - Preview the rendered title + +use itertools::Itertools; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Line; +use strum::IntoEnumIterator; +use strum_macros::Display; +use strum_macros::EnumIter; +use strum_macros::EnumString; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::multi_select_picker::MultiSelectItem; +use crate::bottom_pane::multi_select_picker::MultiSelectPicker; +use crate::render::renderable::Renderable; + +/// Available items that can be displayed in the terminal title. +#[derive(EnumIter, EnumString, Display, Debug, Clone, Copy, Eq, PartialEq, Hash)] +#[strum(serialize_all = "kebab_case")] +pub(crate) enum TerminalTitleItem { + /// Codex app name. + AppName, + /// Project root name, or a compact cwd fallback. + Project, + /// Animated task spinner while active. + Spinner, + /// Compact runtime status text. + Status, + /// Current thread title (if available). + Thread, + /// Current git branch (if available). + GitBranch, + /// Current model name. + Model, + /// Latest checklist task progress from `update_plan` (if available). + TaskProgress, +} + +impl TerminalTitleItem { + pub(crate) fn description(self) -> &'static str { + match self { + TerminalTitleItem::AppName => "Codex app name", + TerminalTitleItem::Project => "Project name (falls back to current directory name)", + TerminalTitleItem::Spinner => { + "Animated task spinner (omitted while idle or when animations are off)" + } + TerminalTitleItem::Status => "Compact session status text (Ready, Working, Thinking)", + TerminalTitleItem::Thread => "Current thread title (omitted until available)", + TerminalTitleItem::GitBranch => "Current Git branch (omitted when unavailable)", + TerminalTitleItem::Model => "Current model name", + TerminalTitleItem::TaskProgress => { + "Latest task progress from update_plan (omitted until available)" + } + } + } + + /// Example text used when previewing the title picker. + /// + /// These are illustrative sample values, not live data from the current + /// session. + pub(crate) fn preview_example(self) -> &'static str { + match self { + TerminalTitleItem::AppName => "codex", + TerminalTitleItem::Project => "my-project", + TerminalTitleItem::Spinner => "⠋", + TerminalTitleItem::Status => "Working", + TerminalTitleItem::Thread => "Investigate flaky test", + TerminalTitleItem::GitBranch => "feat/awesome-feature", + TerminalTitleItem::Model => "gpt-5.2-codex", + TerminalTitleItem::TaskProgress => "Tasks 2/5", + } + } + + pub(crate) fn separator_from_previous(self, previous: Option) -> &'static str { + match previous { + None => "", + Some(previous) + if previous == TerminalTitleItem::Spinner || self == TerminalTitleItem::Spinner => + { + " " + } + Some(_) => " | ", + } + } +} + +fn parse_terminal_title_items(ids: impl Iterator) -> Option> +where + T: AsRef, +{ + // Treat parsing as all-or-nothing so preview/confirm callbacks never emit + // a partially interpreted ordering. Invalid ids are ignored when building + // the picker, but once the user is interacting with the picker we only want + // to persist or preview a fully valid selection. + ids.map(|id| id.as_ref().parse::()) + .collect::, _>>() + .ok() +} + +/// Interactive view for configuring terminal-title items. +pub(crate) struct TerminalTitleSetupView { + picker: MultiSelectPicker, +} + +impl TerminalTitleSetupView { + /// Creates the terminal-title picker, preserving the configured item order first. + /// + /// Unknown configured ids are skipped here instead of surfaced inline. The + /// main TUI still warns about them when rendering the actual title, but the + /// picker itself only exposes the selectable items it can meaningfully + /// preview and persist. + pub(crate) fn new(title_items: Option<&[String]>, app_event_tx: AppEventSender) -> Self { + let selected_items = title_items + .into_iter() + .flatten() + .filter_map(|id| id.parse::().ok()) + .unique() + .collect_vec(); + let selected_set = selected_items + .iter() + .copied() + .collect::>(); + let items = selected_items + .into_iter() + .map(|item| Self::title_select_item(item, /*enabled*/ true)) + .chain( + TerminalTitleItem::iter() + .filter(|item| !selected_set.contains(item)) + .map(|item| Self::title_select_item(item, /*enabled*/ false)), + ) + .collect(); + + Self { + picker: MultiSelectPicker::builder( + "Configure Terminal Title".to_string(), + Some("Select which items to display in the terminal title.".to_string()), + app_event_tx, + ) + .instructions(vec![ + "Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel." + .into(), + ]) + .items(items) + .enable_ordering() + .on_preview(|items| { + let items = parse_terminal_title_items( + items + .iter() + .filter(|item| item.enabled) + .map(|item| item.id.as_str()), + )?; + let mut preview = String::new(); + let mut previous = None; + for item in items.iter().copied() { + preview.push_str(item.separator_from_previous(previous)); + preview.push_str(item.preview_example()); + previous = Some(item); + } + if preview.is_empty() { + None + } else { + Some(Line::from(preview)) + } + }) + .on_change(|items, app_event| { + let Some(items) = parse_terminal_title_items( + items + .iter() + .filter(|item| item.enabled) + .map(|item| item.id.as_str()), + ) else { + return; + }; + app_event.send(AppEvent::TerminalTitleSetupPreview { items }); + }) + .on_confirm(|ids, app_event| { + let Some(items) = parse_terminal_title_items(ids.iter().map(String::as_str)) else { + return; + }; + app_event.send(AppEvent::TerminalTitleSetup { items }); + }) + .on_cancel(|app_event| { + app_event.send(AppEvent::TerminalTitleSetupCancelled); + }) + .build(), + } + } + + fn title_select_item(item: TerminalTitleItem, enabled: bool) -> MultiSelectItem { + MultiSelectItem { + id: item.to_string(), + name: item.to_string(), + description: Some(item.description().to_string()), + enabled, + } + } +} + +impl BottomPaneView for TerminalTitleSetupView { + fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) { + self.picker.handle_key_event(key_event); + } + + fn is_complete(&self) -> bool { + self.picker.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.picker.close(); + CancellationEvent::Handled + } +} + +impl Renderable for TerminalTitleSetupView { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.picker.render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.picker.desired_height(width) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn render_lines(view: &TerminalTitleSetupView, width: u16) -> String { + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let lines: Vec = (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line + }) + .collect(); + lines.join("\n") + } + + #[test] + fn renders_title_setup_popup() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let selected = [ + "project".to_string(), + "spinner".to_string(), + "status".to_string(), + "thread".to_string(), + ]; + let view = TerminalTitleSetupView::new(Some(&selected), tx); + assert_snapshot!("terminal_title_setup_basic", render_lines(&view, 84)); + } + + #[test] + fn parse_terminal_title_items_preserves_order() { + let items = + parse_terminal_title_items(["project", "spinner", "status", "thread"].into_iter()); + assert_eq!( + items, + Some(vec![ + TerminalTitleItem::Project, + TerminalTitleItem::Spinner, + TerminalTitleItem::Status, + TerminalTitleItem::Thread, + ]) + ); + } + + #[test] + fn parse_terminal_title_items_rejects_invalid_ids() { + let items = parse_terminal_title_items(["project", "not-a-title-item"].into_iter()); + assert_eq!(items, None); + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 67d0d8e6ef..a1736171cf 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -44,10 +44,15 @@ use crate::audio_device::list_realtime_audio_device_names; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLinePreviewData; use crate::bottom_pane::StatusLineSetupView; +use crate::bottom_pane::TerminalTitleItem; +use crate::bottom_pane::TerminalTitleSetupView; use crate::status::RateLimitWindowDisplay; use crate::status::format_directory_display; use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; +use crate::terminal_title::SetTerminalTitleResult; +use crate::terminal_title::clear_terminal_title; +use crate::terminal_title::set_terminal_title; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; use codex_app_server_protocol::ConfigLayerSource; @@ -169,6 +174,7 @@ use tokio::sync::mpsc::UnboundedSender; use tokio::task::JoinHandle; use tracing::debug; use tracing::warn; +use unicode_segmentation::UnicodeSegmentation; const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?"; @@ -284,6 +290,11 @@ use self::skills::find_skill_mentions_with_tool_mentions; mod realtime; use self::realtime::RealtimeConversationUiState; use self::realtime::RenderedUserMessageEvent; +mod status_surfaces; +use self::status_surfaces::CachedProjectRootName; +#[cfg(test)] +use self::status_surfaces::TERMINAL_TITLE_SPINNER_INTERVAL; +use self::status_surfaces::TerminalTitleStatusKind; use crate::mention_codec::LinkedMention; use crate::mention_codec::encode_history_mentions; use crate::streaming::chunking::AdaptiveChunkingPolicy; @@ -300,6 +311,7 @@ use codex_file_search::FileMatch; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; @@ -484,6 +496,8 @@ pub(crate) struct ChatWidgetInit { pub(crate) startup_tooltip_override: Option, // Shared latch so we only warn once about invalid status-line item IDs. pub(crate) status_line_invalid_items_warned: Arc, + // Shared latch so we only warn once about invalid terminal-title item IDs. + pub(crate) terminal_title_invalid_items_warned: Arc, pub(crate) session_telemetry: SessionTelemetry, } @@ -709,6 +723,8 @@ pub(crate) struct ChatWidget { // Guardian review keeps its own pending set so it can derive a single // footer summary from one or more in-flight review events. pending_guardian_review_status: PendingGuardianReviewStatus, + // Semantic status used for terminal-title status rendering (avoid string matching on headers). + terminal_title_status_kind: TerminalTitleStatusKind, // Previous status header to restore after a transient stream retry. retry_status_header: Option, // Set when commentary output completes; once stream queues go idle we restore the status row. @@ -771,6 +787,8 @@ pub(crate) struct ChatWidget { // later steer. This is cleared when the user submits a steer so the plan popup only appears // if a newer proposed plan arrives afterward. saw_plan_item_this_turn: bool, + // Latest `update_plan` checklist task counts for terminal-title rendering. + last_plan_progress: Option<(usize, usize)>, // Incremental buffer for streamed plan content. plan_delta_buffer: String, // True while a plan item is streaming. @@ -794,6 +812,21 @@ pub(crate) struct ChatWidget { session_network_proxy: Option, // Shared latch so we only warn once about invalid status-line item IDs. status_line_invalid_items_warned: Arc, + // Shared latch so we only warn once about invalid terminal-title item IDs. + terminal_title_invalid_items_warned: Arc, + // Last terminal title emitted, to avoid writing duplicate OSC updates. + // + // App carries this cache across ChatWidget replacement so the next widget can + // clear a stale title when its own configuration renders no title content. + pub(crate) last_terminal_title: Option, + // Original terminal-title config captured when opening the setup UI so live preview can be + // rolled back on cancel. + terminal_title_setup_original_items: Option>>, + // Baseline instant used to animate spinner-prefixed title statuses. + terminal_title_animation_origin: Instant, + // Cached project root display name for the current cwd; avoids walking parent directories on + // frequent title/status refreshes. + status_line_project_root_name_cache: Option, // Cached git branch name for the status line (None if unknown). status_line_branch: Option, // CWD used to resolve the cached branch; change resets branch state. @@ -1089,12 +1122,15 @@ impl ChatWidget { fn update_task_running_state(&mut self) { self.bottom_pane .set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some()); + self.refresh_terminal_title(); } fn restore_reasoning_status_header(&mut self) { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { + self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(header); } else if self.bottom_pane.is_task_running() { + self.terminal_title_status_kind = TerminalTitleStatusKind::Working; self.set_status_header(String::from("Working")); } } @@ -1187,6 +1223,22 @@ impl ChatWidget { StatusDetailsCapitalization::Preserve, details_max_lines, ); + let title_uses_status = self + .config + .tui_terminal_title + .as_ref() + .is_some_and(|items| items.iter().any(|item| item == "status")); + let title_uses_spinner = self + .config + .tui_terminal_title + .as_ref() + .is_none_or(|items| items.iter().any(|item| item == "spinner")); + if title_uses_status + || (title_uses_spinner + && self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing) + { + self.refresh_terminal_title(); + } } /// Convenience wrapper around [`Self::set_status`]; @@ -1213,70 +1265,6 @@ impl ChatWidget { self.bottom_pane.set_active_agent_label(active_agent_label); } - /// Recomputes footer status-line content from config and current runtime state. - /// - /// This method is the status-line orchestrator: it parses configured item identifiers, - /// warns once per session about invalid items, updates whether status-line mode is enabled, - /// schedules async git-branch lookup when needed, and renders only values that are currently - /// available. - /// - /// The omission behavior is intentional. If selected items are unavailable (for example before - /// a session id exists or before branch lookup completes), those items are skipped without - /// placeholders so the line remains compact and stable. - pub(crate) fn refresh_status_line(&mut self) { - let (items, invalid_items) = self.status_line_items_with_invalids(); - if self.thread_id.is_some() - && !invalid_items.is_empty() - && self - .status_line_invalid_items_warned - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - let label = if invalid_items.len() == 1 { - "item" - } else { - "items" - }; - let message = format!( - "Ignored invalid status line {label}: {}.", - proper_join(invalid_items.as_slice()) - ); - self.on_warning(message); - } - if !items.contains(&StatusLineItem::GitBranch) { - self.status_line_branch = None; - self.status_line_branch_pending = false; - self.status_line_branch_lookup_complete = false; - } - let enabled = !items.is_empty(); - self.bottom_pane.set_status_line_enabled(enabled); - if !enabled { - self.set_status_line(/*status_line*/ None); - return; - } - - let cwd = self.status_line_cwd().to_path_buf(); - self.sync_status_line_branch_state(&cwd); - - if items.contains(&StatusLineItem::GitBranch) && !self.status_line_branch_lookup_complete { - self.request_status_line_branch(cwd); - } - - let mut parts = Vec::new(); - for item in items { - if let Some(value) = self.status_line_value_for_item(&item) { - parts.push(value); - } - } - - let line = if parts.is_empty() { - None - } else { - Some(Line::from(parts.join(" · "))) - }; - self.set_status_line(line); - } - /// Records that status-line setup was canceled. /// /// Cancellation is intentionally side-effect free for config state; the existing configuration @@ -1292,7 +1280,45 @@ impl ChatWidget { tracing::info!("status line setup confirmed with items: {items:#?}"); let ids = items.iter().map(ToString::to_string).collect::>(); self.config.tui_status_line = Some(ids); - self.refresh_status_line(); + self.refresh_status_surfaces(); + } + + /// Applies a temporary terminal-title selection while the setup UI is open. + pub(crate) fn preview_terminal_title(&mut self, items: Vec) { + if self.terminal_title_setup_original_items.is_none() { + self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); + } + + let ids = items.iter().map(ToString::to_string).collect::>(); + self.config.tui_terminal_title = Some(ids); + self.refresh_terminal_title(); + } + + /// Restores the terminal title selection captured before opening the setup UI. + pub(crate) fn revert_terminal_title_setup_preview(&mut self) { + let Some(original_items) = self.terminal_title_setup_original_items.take() else { + return; + }; + + self.config.tui_terminal_title = original_items; + self.refresh_terminal_title(); + } + + /// Records that terminal-title setup was canceled and rolls back live preview changes. + pub(crate) fn cancel_terminal_title_setup(&mut self) { + tracing::info!("Terminal title setup canceled by user"); + self.revert_terminal_title_setup_preview(); + } + + /// Applies terminal-title item selection from the setup view to in-memory config. + /// + /// An empty selection persists as an explicit empty list (disables title updates). + pub(crate) fn setup_terminal_title(&mut self, items: Vec) { + tracing::info!("terminal title setup confirmed with items: {items:#?}"); + let ids = items.iter().map(ToString::to_string).collect::>(); + self.terminal_title_setup_original_items = None; + self.config.tui_terminal_title = Some(ids); + self.refresh_terminal_title(); } /// Stores async git-branch lookup results for the current status-line cwd. @@ -1309,17 +1335,6 @@ impl ChatWidget { self.status_line_branch_lookup_complete = true; } - /// Forces a new git-branch lookup when `GitBranch` is part of the configured status line. - fn request_status_line_branch_refresh(&mut self) { - let (items, _) = self.status_line_items_with_invalids(); - if items.is_empty() || !items.contains(&StatusLineItem::GitBranch) { - return; - } - let cwd = self.status_line_cwd().to_path_buf(); - self.sync_status_line_branch_state(&cwd); - self.request_status_line_branch(cwd); - } - fn collect_runtime_metrics_delta(&mut self) { if let Some(delta) = self.session_telemetry.runtime_metrics_summary() { self.apply_runtime_metrics_delta(delta); @@ -1385,6 +1400,7 @@ impl ChatWidget { Constrained::allow_only(event.sandbox_policy.clone()); } self.config.approvals_reviewer = event.approvals_reviewer; + self.status_line_project_root_name_cache = None; let initial_messages = event.initial_messages.clone(); self.last_copyable_output = None; let forked_from_id = event.forked_from_id; @@ -1488,6 +1504,7 @@ impl ChatWidget { fn on_thread_name_updated(&mut self, event: codex_protocol::protocol::ThreadNameUpdatedEvent) { if self.thread_id == Some(event.thread_id) { self.thread_name = event.thread_name; + self.refresh_terminal_title(); self.request_redraw(); } } @@ -1659,6 +1676,7 @@ impl ChatWidget { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { // Update the shimmer header to the extracted reasoning chunk header. + self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(header); } else { // Fallback while we don't yet have a bold header: leave existing header as-is. @@ -1696,6 +1714,7 @@ impl ChatWidget { .set_turn_running(/*turn_running*/ true); self.saw_plan_update_this_turn = false; self.saw_plan_item_this_turn = false; + self.last_plan_progress = None; self.plan_delta_buffer.clear(); self.plan_item_active = false; self.adaptive_chunking.reset(); @@ -1710,6 +1729,7 @@ impl ChatWidget { self.pending_status_indicator_restore = false; self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); + self.terminal_title_status_kind = TerminalTitleStatusKind::Working; self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); @@ -2048,7 +2068,7 @@ impl ChatWidget { } else { self.rate_limit_snapshots_by_limit_id.clear(); } - self.refresh_status_line(); + self.refresh_status_surfaces(); } /// Finalize any active exec as failed and stop/clear agent-turn UI state. /// @@ -2353,6 +2373,17 @@ impl ChatWidget { fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.saw_plan_update_this_turn = true; + let total = update.plan.len(); + let completed = update + .plan + .iter() + .filter(|item| match &item.status { + StepStatus::Completed => true, + StepStatus::Pending | StepStatus::InProgress => false, + }) + .count(); + self.last_plan_progress = (total > 0).then_some((completed, total)); + self.refresh_terminal_title(); self.add_to_history(history_cell::new_plan_update(update)); } @@ -2671,6 +2702,7 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); + self.terminal_title_status_kind = TerminalTitleStatusKind::WaitingForBackgroundTerminal; self.set_status( "Waiting for background terminal".to_string(), command_display.clone(), @@ -2913,7 +2945,7 @@ impl ChatWidget { fn on_turn_diff(&mut self, unified_diff: String) { debug!("TurnDiffEvent: {unified_diff}"); - self.refresh_status_line(); + self.refresh_status_surfaces(); } fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { @@ -2927,6 +2959,7 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); + self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(message); } @@ -2968,12 +3001,15 @@ impl ChatWidget { let message = event .message .unwrap_or_else(|| "Undo in progress...".to_string()); + self.terminal_title_status_kind = TerminalTitleStatusKind::Undoing; self.set_status_header(message); } fn on_undo_completed(&mut self, event: UndoCompletedEvent) { let UndoCompletedEvent { success, message } = event; self.bottom_pane.hide_status_indicator(); + self.terminal_title_status_kind = TerminalTitleStatusKind::Working; + self.refresh_terminal_title(); let message = message.unwrap_or_else(|| { if success { "Undo completed successfully.".to_string() @@ -2993,6 +3029,7 @@ impl ChatWidget { self.retry_status_header = Some(self.current_status.header.clone()); } self.bottom_pane.ensure_status_indicator(); + self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status( message, additional_details, @@ -3003,6 +3040,9 @@ impl ChatWidget { pub(crate) fn pre_draw_tick(&mut self) { self.bottom_pane.pre_draw_tick(); + if self.should_animate_terminal_title_spinner() { + self.refresh_terminal_title(); + } } /// Handle completion of an `AgentMessage` turn item. @@ -3525,6 +3565,7 @@ impl ChatWidget { model, startup_tooltip_override, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, session_telemetry, } = common; let model = model.filter(|m| !m.trim().is_empty()); @@ -3616,6 +3657,7 @@ impl ChatWidget { full_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, @@ -3638,6 +3680,7 @@ impl ChatWidget { had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, last_separator_elapsed_secs: None, @@ -3649,6 +3692,11 @@ impl ChatWidget { current_cwd, session_network_proxy: None, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + last_terminal_title: None, + terminal_title_setup_original_items: None, + terminal_title_animation_origin: Instant::now(), + status_line_project_root_name_cache: None, status_line_branch: None, status_line_branch_cwd: None, status_line_branch_pending: false, @@ -3693,6 +3741,8 @@ impl ChatWidget { .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + widget.refresh_terminal_title(); + widget } @@ -3714,6 +3764,7 @@ impl ChatWidget { model, startup_tooltip_override, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, session_telemetry, } = common; let model = model.filter(|m| !m.trim().is_empty()); @@ -3804,6 +3855,7 @@ impl ChatWidget { full_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, @@ -3812,6 +3864,7 @@ impl ChatWidget { forked_from: None, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), @@ -3837,6 +3890,11 @@ impl ChatWidget { current_cwd, session_network_proxy: None, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + last_terminal_title: None, + terminal_title_setup_original_items: None, + terminal_title_animation_origin: Instant::now(), + status_line_project_root_name_cache: None, status_line_branch: None, status_line_branch_cwd: None, status_line_branch_pending: false, @@ -3870,6 +3928,8 @@ impl ChatWidget { widget .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + widget.refresh_terminal_title(); + widget.refresh_terminal_title(); widget } @@ -3894,6 +3954,7 @@ impl ChatWidget { model, startup_tooltip_override: _, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, session_telemetry, } = common; let model = model.filter(|m| !m.trim().is_empty()); @@ -3984,6 +4045,7 @@ impl ChatWidget { full_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, @@ -4006,6 +4068,7 @@ impl ChatWidget { had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, last_separator_elapsed_secs: None, @@ -4017,6 +4080,11 @@ impl ChatWidget { current_cwd, session_network_proxy: None, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + last_terminal_title: None, + terminal_title_setup_original_items: None, + terminal_title_animation_origin: Instant::now(), + status_line_project_root_name_cache: None, status_line_branch: None, status_line_branch_cwd: None, status_line_branch_pending: false, @@ -4059,6 +4127,8 @@ impl ChatWidget { widget .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + widget.refresh_terminal_title(); + widget.refresh_terminal_title(); widget } @@ -4556,6 +4626,9 @@ impl ChatWidget { SlashCommand::DebugConfig => { self.add_debug_config_output(); } + SlashCommand::Title => { + self.open_terminal_title_setup(); + } SlashCommand::Statusline => { self.open_status_line_setup(); } @@ -5748,188 +5821,14 @@ impl ChatWidget { self.bottom_pane.show_selection_view(params); } - /// Parses configured status-line ids into known items and collects unknown ids. - /// - /// Unknown ids are deduplicated in insertion order for warning messages. - fn status_line_items_with_invalids(&self) -> (Vec, Vec) { - let mut invalid = Vec::new(); - let mut invalid_seen = HashSet::new(); - let mut items = Vec::new(); - for id in self.configured_status_line_items() { - match id.parse::() { - Ok(item) => items.push(item), - Err(_) => { - if invalid_seen.insert(id.clone()) { - invalid.push(format!(r#""{id}""#)); - } - } - } - } - (items, invalid) - } - - fn configured_status_line_items(&self) -> Vec { - self.config.tui_status_line.clone().unwrap_or_else(|| { - DEFAULT_STATUS_LINE_ITEMS - .iter() - .map(ToString::to_string) - .collect() - }) - } - - fn status_line_cwd(&self) -> &Path { - self.current_cwd.as_ref().unwrap_or(&self.config.cwd) - } - - fn status_line_project_root(&self) -> Option { - let cwd = self.status_line_cwd(); - if let Some(repo_root) = get_git_repo_root(cwd) { - return Some(repo_root); - } - - self.config - .config_layer_stack - .get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ true, - ) - .iter() - .find_map(|layer| match &layer.name { - ConfigLayerSource::Project { dot_codex_folder } => { - dot_codex_folder.as_path().parent().map(Path::to_path_buf) - } - _ => None, - }) - } - - fn status_line_project_root_name(&self) -> Option { - self.status_line_project_root().map(|root| { - root.file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_else(|| format_directory_display(&root, /*max_width*/ None)) - }) - } - - /// Resets git-branch cache state when the status-line cwd changes. - /// - /// The branch cache is keyed by cwd because branch lookup is performed relative to that path. - /// Keeping stale branch values across cwd changes would surface incorrect repository context. - fn sync_status_line_branch_state(&mut self, cwd: &Path) { - if self - .status_line_branch_cwd - .as_ref() - .is_some_and(|path| path == cwd) - { - return; - } - self.status_line_branch_cwd = Some(cwd.to_path_buf()); - self.status_line_branch = None; - self.status_line_branch_pending = false; - self.status_line_branch_lookup_complete = false; - } - - /// Starts an async git-branch lookup unless one is already running. - /// - /// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject - /// stale completions after directory changes. - fn request_status_line_branch(&mut self, cwd: PathBuf) { - if self.status_line_branch_pending { - return; - } - self.status_line_branch_pending = true; - let tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let branch = current_branch_name(&cwd).await; - tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch }); - }); - } - - /// Resolves a display string for one configured status-line item. - /// - /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on - /// this to keep partially available status lines readable while waiting for session, token, or - /// git metadata. - fn status_line_value_for_item(&self, item: &StatusLineItem) -> Option { - match item { - StatusLineItem::ModelName => Some(self.model_display_name().to_string()), - StatusLineItem::ModelWithReasoning => { - let label = - Self::status_line_reasoning_effort_label(self.effective_reasoning_effort()); - let fast_label = if self - .should_show_fast_status(self.current_model(), self.config.service_tier) - { - " fast" - } else { - "" - }; - Some(format!("{} {label}{fast_label}", self.model_display_name())) - } - StatusLineItem::CurrentDir => { - Some(format_directory_display( - self.status_line_cwd(), - /*max_width*/ None, - )) - } - StatusLineItem::ProjectRoot => self.status_line_project_root_name(), - StatusLineItem::GitBranch => self.status_line_branch.clone(), - StatusLineItem::UsedTokens => { - let usage = self.status_line_total_usage(); - let total = usage.tokens_in_context_window(); - if total <= 0 { - None - } else { - Some(format!("{} used", format_tokens_compact(total))) - } - } - StatusLineItem::ContextRemaining => self - .status_line_context_remaining_percent() - .map(|remaining| format!("{remaining}% left")), - StatusLineItem::ContextUsed => self - .status_line_context_used_percent() - .map(|used| format!("{used}% used")), - StatusLineItem::FiveHourLimit => { - let window = self - .rate_limit_snapshots_by_limit_id - .get("codex") - .and_then(|s| s.primary.as_ref()); - let label = window - .and_then(|window| window.window_minutes) - .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()); - self.status_line_limit_display(window, &label) - } - StatusLineItem::WeeklyLimit => { - let window = self - .rate_limit_snapshots_by_limit_id - .get("codex") - .and_then(|s| s.secondary.as_ref()); - let label = window - .and_then(|window| window.window_minutes) - .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()); - self.status_line_limit_display(window, &label) - } - StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), - StatusLineItem::ContextWindowSize => self - .status_line_context_window_size() - .map(|cws| format!("{} window", format_tokens_compact(cws))), - StatusLineItem::TotalInputTokens => Some(format!( - "{} in", - format_tokens_compact(self.status_line_total_usage().input_tokens) - )), - StatusLineItem::TotalOutputTokens => Some(format!( - "{} out", - format_tokens_compact(self.status_line_total_usage().output_tokens) - )), - StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()), - StatusLineItem::FastMode => Some( - if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { - "Fast on".to_string() - } else { - "Fast off".to_string() - }, - ), - } + fn open_terminal_title_setup(&mut self) { + let configured_terminal_title_items = self.configured_terminal_title_items(); + self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); + let view = TerminalTitleSetupView::new( + Some(configured_terminal_title_items.as_slice()), + self.app_event_tx.clone(), + ); + self.bottom_pane.show_view(Box::new(view)); } fn status_line_context_window_size(&self) -> Option { @@ -8187,6 +8086,7 @@ impl ChatWidget { self.session_header.set_model(effective.model()); // Keep composer paste affordances aligned with the currently effective model. self.sync_image_paste_enabled(); + self.refresh_terminal_title(); } fn model_display_name(&self) -> &str { @@ -9288,8 +9188,8 @@ fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool { impl Drop for ChatWidget { fn drop(&mut self) { - self.reset_realtime_conversation_state(); self.stop_rate_limit_poller(); + self.reset_realtime_conversation_state(); } } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs new file mode 100644 index 0000000000..5888b3b1b9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -0,0 +1,660 @@ +//! Status-line and terminal-title rendering helpers for `ChatWidget`. +//! +//! Keeping this logic in a focused submodule makes the additive title/status +//! behavior easier to review without paging through the rest of `chatwidget.rs`. + +use super::*; + +pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["spinner", "project"]; +pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] = + ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100); + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +/// Compact runtime states that can be rendered into the terminal title. +/// +/// This is intentionally smaller than the full status-header vocabulary. The +/// title needs short, stable labels, so callers map richer lifecycle events +/// onto one of these buckets before rendering. +pub(super) enum TerminalTitleStatusKind { + Working, + WaitingForBackgroundTerminal, + Undoing, + #[default] + Thinking, +} + +#[derive(Debug)] +/// Parsed status-surface configuration for one refresh pass. +/// +/// The status line and terminal title share some expensive or stateful inputs +/// (notably git branch lookup and invalid-item warnings). This snapshot lets one +/// refresh pass compute those shared concerns once, then render both surfaces +/// from the same selection set. +struct StatusSurfaceSelections { + status_line_items: Vec, + invalid_status_line_items: Vec, + terminal_title_items: Vec, + invalid_terminal_title_items: Vec, +} + +impl StatusSurfaceSelections { + fn uses_git_branch(&self) -> bool { + self.status_line_items.contains(&StatusLineItem::GitBranch) + || self + .terminal_title_items + .contains(&TerminalTitleItem::GitBranch) + } +} + +#[derive(Clone, Debug)] +/// Cached project-root display name keyed by the cwd used for the last lookup. +/// +/// Terminal-title refreshes can happen very frequently, so the title path avoids +/// repeatedly walking up the filesystem to rediscover the same project root name +/// while the working directory is unchanged. +pub(super) struct CachedProjectRootName { + pub(super) cwd: PathBuf, + pub(super) root_name: Option, +} + +impl ChatWidget { + fn status_surface_selections(&self) -> StatusSurfaceSelections { + let (status_line_items, invalid_status_line_items) = self.status_line_items_with_invalids(); + let (terminal_title_items, invalid_terminal_title_items) = + self.terminal_title_items_with_invalids(); + StatusSurfaceSelections { + status_line_items, + invalid_status_line_items, + terminal_title_items, + invalid_terminal_title_items, + } + } + + fn warn_invalid_status_line_items_once(&mut self, invalid_items: &[String]) { + if self.thread_id.is_some() + && !invalid_items.is_empty() + && self + .status_line_invalid_items_warned + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + let label = if invalid_items.len() == 1 { + "item" + } else { + "items" + }; + let message = format!( + "Ignored invalid status line {label}: {}.", + proper_join(invalid_items) + ); + self.on_warning(message); + } + } + + fn warn_invalid_terminal_title_items_once(&mut self, invalid_items: &[String]) { + if self.thread_id.is_some() + && !invalid_items.is_empty() + && self + .terminal_title_invalid_items_warned + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + let label = if invalid_items.len() == 1 { + "item" + } else { + "items" + }; + let message = format!( + "Ignored invalid terminal title {label}: {}.", + proper_join(invalid_items) + ); + self.on_warning(message); + } + } + + fn sync_status_surface_shared_state(&mut self, selections: &StatusSurfaceSelections) { + if !selections.uses_git_branch() { + self.status_line_branch = None; + self.status_line_branch_pending = false; + self.status_line_branch_lookup_complete = false; + return; + } + + let cwd = self.status_line_cwd().to_path_buf(); + self.sync_status_line_branch_state(&cwd); + if !self.status_line_branch_lookup_complete { + self.request_status_line_branch(cwd); + } + } + + fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) { + let enabled = !selections.status_line_items.is_empty(); + self.bottom_pane.set_status_line_enabled(enabled); + if !enabled { + self.set_status_line(/*status_line*/ None); + return; + } + + let mut parts = Vec::new(); + for item in &selections.status_line_items { + if let Some(value) = self.status_line_value_for_item(item) { + parts.push(value); + } + } + + let line = if parts.is_empty() { + None + } else { + Some(Line::from(parts.join(" · "))) + }; + self.set_status_line(line); + } + + /// Clears the terminal title Codex most recently wrote, if any. + /// + /// This does not attempt to restore the shell or terminal's previous title; + /// it only clears the managed title and updates the cache after a successful + /// OSC write. + pub(crate) fn clear_managed_terminal_title(&mut self) -> std::io::Result<()> { + if self.last_terminal_title.is_some() { + clear_terminal_title()?; + self.last_terminal_title = None; + } + + Ok(()) + } + + /// Renders and applies the terminal title for one parsed selection snapshot. + /// + /// Empty selections clear the managed title. Non-empty selections render the + /// current values in configured order, skip unavailable segments, and cache + /// the last successfully written title so redundant OSC writes are avoided. + /// When the `spinner` item is present in an animated running state, this also + /// schedules the next frame so the spinner keeps advancing. + fn refresh_terminal_title_from_selections(&mut self, selections: &StatusSurfaceSelections) { + if selections.terminal_title_items.is_empty() { + if let Err(err) = self.clear_managed_terminal_title() { + tracing::debug!(error = %err, "failed to clear terminal title"); + } + return; + } + + let now = Instant::now(); + let mut previous = None; + let title = selections + .terminal_title_items + .iter() + .copied() + .filter_map(|item| { + self.terminal_title_value_for_item(item, now) + .map(|value| (item, value)) + }) + .fold(String::new(), |mut title, (item, value)| { + title.push_str(item.separator_from_previous(previous)); + title.push_str(&value); + previous = Some(item); + title + }); + let title = (!title.is_empty()).then_some(title); + let should_animate_spinner = + self.should_animate_terminal_title_spinner_with_selections(selections); + if self.last_terminal_title == title { + if should_animate_spinner { + self.frame_requester + .schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL); + } + return; + } + match title { + Some(title) => match set_terminal_title(&title) { + Ok(SetTerminalTitleResult::Applied) => { + self.last_terminal_title = Some(title); + } + Ok(SetTerminalTitleResult::NoVisibleContent) => { + if let Err(err) = self.clear_managed_terminal_title() { + tracing::debug!(error = %err, "failed to clear terminal title"); + } + } + Err(err) => { + tracing::debug!(error = %err, "failed to set terminal title"); + } + }, + None => { + if let Err(err) = self.clear_managed_terminal_title() { + tracing::debug!(error = %err, "failed to clear terminal title"); + } + } + } + + if should_animate_spinner { + self.frame_requester + .schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL); + } + } + + /// Recomputes both status surfaces from one shared config snapshot. + /// + /// This is the common refresh entrypoint for the footer status line and the + /// terminal title. It parses both configurations once, emits invalid-item + /// warnings once, synchronizes shared cached state (such as git-branch + /// lookup), then renders each surface from that shared snapshot. + pub(crate) fn refresh_status_surfaces(&mut self) { + let selections = self.status_surface_selections(); + self.warn_invalid_status_line_items_once(&selections.invalid_status_line_items); + self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items); + self.sync_status_surface_shared_state(&selections); + self.refresh_status_line_from_selections(&selections); + self.refresh_terminal_title_from_selections(&selections); + } + + /// Recomputes and emits the terminal title from config and runtime state. + pub(crate) fn refresh_terminal_title(&mut self) { + let selections = self.status_surface_selections(); + self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items); + self.sync_status_surface_shared_state(&selections); + self.refresh_terminal_title_from_selections(&selections); + } + + pub(super) fn request_status_line_branch_refresh(&mut self) { + let selections = self.status_surface_selections(); + if !selections.uses_git_branch() { + return; + } + let cwd = self.status_line_cwd().to_path_buf(); + self.sync_status_line_branch_state(&cwd); + self.request_status_line_branch(cwd); + } + + /// Parses configured status-line ids into known items and collects unknown ids. + /// + /// Unknown ids are deduplicated in insertion order for warning messages. + fn status_line_items_with_invalids(&self) -> (Vec, Vec) { + let mut invalid = Vec::new(); + let mut invalid_seen = HashSet::new(); + let mut items = Vec::new(); + for id in self.configured_status_line_items() { + match id.parse::() { + Ok(item) => items.push(item), + Err(_) => { + if invalid_seen.insert(id.clone()) { + invalid.push(format!(r#""{id}""#)); + } + } + } + } + (items, invalid) + } + + pub(super) fn configured_status_line_items(&self) -> Vec { + self.config.tui_status_line.clone().unwrap_or_else(|| { + DEFAULT_STATUS_LINE_ITEMS + .iter() + .map(ToString::to_string) + .collect() + }) + } + + /// Parses configured terminal-title ids into known items and collects unknown ids. + /// + /// Unknown ids are deduplicated in insertion order for warning messages. + fn terminal_title_items_with_invalids(&self) -> (Vec, Vec) { + let mut invalid = Vec::new(); + let mut invalid_seen = HashSet::new(); + let mut items = Vec::new(); + for id in self.configured_terminal_title_items() { + match id.parse::() { + Ok(item) => items.push(item), + Err(_) => { + if invalid_seen.insert(id.clone()) { + invalid.push(format!(r#""{id}""#)); + } + } + } + } + (items, invalid) + } + + /// Returns the configured terminal-title ids, or the default ordering when unset. + pub(super) fn configured_terminal_title_items(&self) -> Vec { + self.config.tui_terminal_title.clone().unwrap_or_else(|| { + DEFAULT_TERMINAL_TITLE_ITEMS + .iter() + .map(ToString::to_string) + .collect() + }) + } + + fn status_line_cwd(&self) -> &Path { + self.current_cwd.as_ref().unwrap_or(&self.config.cwd) + } + + /// Resolves the project root associated with `cwd`. + /// + /// Git repository root wins when available. Otherwise we fall back to the + /// nearest project config layer so non-git projects can still surface a + /// stable project label. + fn status_line_project_root_for_cwd(&self, cwd: &Path) -> Option { + if let Some(repo_root) = get_git_repo_root(cwd) { + return Some(repo_root); + } + + self.config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .iter() + .find_map(|layer| match &layer.name { + ConfigLayerSource::Project { dot_codex_folder } => { + dot_codex_folder.as_path().parent().map(Path::to_path_buf) + } + _ => None, + }) + } + + fn status_line_project_root_name_for_cwd(&self, cwd: &Path) -> Option { + self.status_line_project_root_for_cwd(cwd).map(|root| { + root.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| format_directory_display(&root, /*max_width*/ None)) + }) + } + + /// Returns a cached project-root display name for the active cwd. + fn status_line_project_root_name(&mut self) -> Option { + let cwd = self.status_line_cwd().to_path_buf(); + if let Some(cache) = &self.status_line_project_root_name_cache + && cache.cwd == cwd + { + return cache.root_name.clone(); + } + + let root_name = self.status_line_project_root_name_for_cwd(&cwd); + self.status_line_project_root_name_cache = Some(CachedProjectRootName { + cwd, + root_name: root_name.clone(), + }); + root_name + } + + /// Produces the terminal-title `project` value. + /// + /// This prefers the cached project-root name and falls back to the current + /// directory name when no project root can be inferred. + fn terminal_title_project_name(&mut self) -> Option { + let project = self.status_line_project_root_name().or_else(|| { + let cwd = self.status_line_cwd(); + Some( + cwd.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| format_directory_display(cwd, /*max_width*/ None)), + ) + })?; + Some(Self::truncate_terminal_title_part( + project, /*max_chars*/ 24, + )) + } + + /// Resets git-branch cache state when the status-line cwd changes. + /// + /// The branch cache is keyed by cwd because branch lookup is performed relative to that path. + /// Keeping stale branch values across cwd changes would surface incorrect repository context. + fn sync_status_line_branch_state(&mut self, cwd: &Path) { + if self + .status_line_branch_cwd + .as_ref() + .is_some_and(|path| path == cwd) + { + return; + } + self.status_line_branch_cwd = Some(cwd.to_path_buf()); + self.status_line_branch = None; + self.status_line_branch_pending = false; + self.status_line_branch_lookup_complete = false; + } + + /// Starts an async git-branch lookup unless one is already running. + /// + /// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject + /// stale completions after directory changes. + fn request_status_line_branch(&mut self, cwd: PathBuf) { + if self.status_line_branch_pending { + return; + } + self.status_line_branch_pending = true; + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let branch = current_branch_name(&cwd).await; + tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch }); + }); + } + + /// Resolves a display string for one configured status-line item. + /// + /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on + /// this to keep partially available status lines readable while waiting for session, token, or + /// git metadata. + pub(super) fn status_line_value_for_item(&mut self, item: &StatusLineItem) -> Option { + match item { + StatusLineItem::ModelName => Some(self.model_display_name().to_string()), + StatusLineItem::ModelWithReasoning => { + let label = + Self::status_line_reasoning_effort_label(self.effective_reasoning_effort()); + let fast_label = if self + .should_show_fast_status(self.current_model(), self.config.service_tier) + { + " fast" + } else { + "" + }; + Some(format!("{} {label}{fast_label}", self.model_display_name())) + } + StatusLineItem::CurrentDir => { + Some(format_directory_display( + self.status_line_cwd(), + /*max_width*/ None, + )) + } + StatusLineItem::ProjectRoot => self.status_line_project_root_name(), + StatusLineItem::GitBranch => self.status_line_branch.clone(), + StatusLineItem::UsedTokens => { + let usage = self.status_line_total_usage(); + let total = usage.tokens_in_context_window(); + if total <= 0 { + None + } else { + Some(format!("{} used", format_tokens_compact(total))) + } + } + StatusLineItem::ContextRemaining => self + .status_line_context_remaining_percent() + .map(|remaining| format!("{remaining}% left")), + StatusLineItem::ContextUsed => self + .status_line_context_used_percent() + .map(|used| format!("{used}% used")), + StatusLineItem::FiveHourLimit => { + let window = self + .rate_limit_snapshots_by_limit_id + .get("codex") + .and_then(|s| s.primary.as_ref()); + let label = window + .and_then(|window| window.window_minutes) + .map(get_limits_duration) + .unwrap_or_else(|| "5h".to_string()); + self.status_line_limit_display(window, &label) + } + StatusLineItem::WeeklyLimit => { + let window = self + .rate_limit_snapshots_by_limit_id + .get("codex") + .and_then(|s| s.secondary.as_ref()); + let label = window + .and_then(|window| window.window_minutes) + .map(get_limits_duration) + .unwrap_or_else(|| "weekly".to_string()); + self.status_line_limit_display(window, &label) + } + StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), + StatusLineItem::ContextWindowSize => self + .status_line_context_window_size() + .map(|cws| format!("{} window", format_tokens_compact(cws))), + StatusLineItem::TotalInputTokens => Some(format!( + "{} in", + format_tokens_compact(self.status_line_total_usage().input_tokens) + )), + StatusLineItem::TotalOutputTokens => Some(format!( + "{} out", + format_tokens_compact(self.status_line_total_usage().output_tokens) + )), + StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()), + StatusLineItem::FastMode => Some( + if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { + "Fast on".to_string() + } else { + "Fast off".to_string() + }, + ), + } + } + + /// Resolves one configured terminal-title item into a displayable segment. + /// + /// Returning `None` means "omit this segment for now" so callers can keep + /// the configured order while hiding values that are not yet available. + fn terminal_title_value_for_item( + &mut self, + item: TerminalTitleItem, + now: Instant, + ) -> Option { + match item { + TerminalTitleItem::AppName => Some("codex".to_string()), + TerminalTitleItem::Project => self.terminal_title_project_name(), + TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now), + TerminalTitleItem::Status => Some(self.terminal_title_status_text()), + TerminalTitleItem::Thread => self.thread_name.as_ref().and_then(|name| { + let trimmed = name.trim(); + if trimmed.is_empty() { + None + } else { + Some(Self::truncate_terminal_title_part( + trimmed.to_string(), + /*max_chars*/ 48, + )) + } + }), + TerminalTitleItem::GitBranch => self.status_line_branch.as_ref().map(|branch| { + Self::truncate_terminal_title_part(branch.clone(), /*max_chars*/ 32) + }), + TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part( + self.model_display_name().to_string(), + /*max_chars*/ 32, + )), + TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(), + } + } + + /// Computes the compact runtime status label used by the terminal title. + /// + /// Startup takes precedence over normal task states, and idle state renders + /// as `Ready` regardless of the last active status bucket. + pub(super) fn terminal_title_status_text(&self) -> String { + if self.mcp_startup_status.is_some() { + return "Starting".to_string(); + } + + match self.terminal_title_status_kind { + TerminalTitleStatusKind::Working if !self.bottom_pane.is_task_running() => { + "Ready".to_string() + } + TerminalTitleStatusKind::WaitingForBackgroundTerminal + if !self.bottom_pane.is_task_running() => + { + "Ready".to_string() + } + TerminalTitleStatusKind::Thinking if !self.bottom_pane.is_task_running() => { + "Ready".to_string() + } + TerminalTitleStatusKind::Working => "Working".to_string(), + TerminalTitleStatusKind::WaitingForBackgroundTerminal => "Waiting".to_string(), + TerminalTitleStatusKind::Undoing => "Undoing".to_string(), + TerminalTitleStatusKind::Thinking => "Thinking".to_string(), + } + } + + pub(super) fn terminal_title_spinner_text_at(&self, now: Instant) -> Option { + if !self.config.animations { + return None; + } + + if !self.terminal_title_has_active_progress() { + return None; + } + + Some(self.terminal_title_spinner_frame_at(now).to_string()) + } + + fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str { + let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin); + let frame_index = + (elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize; + TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()] + } + + fn terminal_title_uses_spinner(&self) -> bool { + self.config + .tui_terminal_title + .as_ref() + .is_none_or(|items| items.iter().any(|item| item == "spinner")) + } + + fn terminal_title_has_active_progress(&self) -> bool { + self.mcp_startup_status.is_some() + || self.bottom_pane.is_task_running() + || self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing + } + + pub(super) fn should_animate_terminal_title_spinner(&self) -> bool { + self.config.animations + && self.terminal_title_uses_spinner() + && self.terminal_title_has_active_progress() + } + + fn should_animate_terminal_title_spinner_with_selections( + &self, + selections: &StatusSurfaceSelections, + ) -> bool { + self.config.animations + && selections + .terminal_title_items + .contains(&TerminalTitleItem::Spinner) + && self.terminal_title_has_active_progress() + } + + /// Formats the last `update_plan` progress snapshot for terminal-title display. + pub(super) fn terminal_title_task_progress(&self) -> Option { + let (completed, total) = self.last_plan_progress?; + if total == 0 { + return None; + } + Some(format!("Tasks {completed}/{total}")) + } + + /// Truncates a title segment by grapheme cluster and appends `...` when needed. + pub(super) fn truncate_terminal_title_part(value: String, max_chars: usize) -> String { + if max_chars == 0 { + return String::new(); + } + + let mut graphemes = value.graphemes(true); + let head: String = graphemes.by_ref().take(max_chars).collect(); + if graphemes.next().is_none() || max_chars <= 3 { + return head; + } + + let mut truncated = head.graphemes(true).take(max_chars - 3).collect::(); + truncated.push_str("..."); + truncated + } +} diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4f216ba2e0..05adf3d307 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1777,6 +1777,7 @@ async fn helpers_are_available_and_do_not_panic() { model: Some(resolved_model), startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), session_telemetry, }; let mut w = ChatWidget::new(init, thread_manager); @@ -1896,6 +1897,7 @@ async fn make_chatwidget_manual( reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, @@ -1919,6 +1921,7 @@ async fn make_chatwidget_manual( had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, last_separator_elapsed_secs: None, @@ -1930,6 +1933,11 @@ async fn make_chatwidget_manual( current_cwd: None, session_network_proxy: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + last_terminal_title: None, + terminal_title_setup_original_items: None, + terminal_title_animation_origin: Instant::now(), + status_line_project_root_name_cache: None, status_line_branch: None, status_line_branch_cwd: None, status_line_branch_pending: false, @@ -5716,6 +5724,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() { model: Some(resolved_model.clone()), startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), session_telemetry, }; @@ -5766,6 +5775,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() { model: Some(resolved_model.clone()), startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), session_telemetry, }; @@ -6322,6 +6332,50 @@ async fn undo_started_hides_interrupt_hint() { ); } +#[tokio::test] +async fn undo_completed_clears_terminal_title_undo_state() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = true; + chat.config.tui_terminal_title = Some(vec!["spinner".to_string(), "status".to_string()]); + chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1); + + chat.handle_codex_event(Event { + id: "turn-undo".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), + }); + + assert_eq!(chat.last_terminal_title, Some("⠋ Undoing".to_string())); + + chat.handle_codex_event(Event { + id: "turn-undo".to_string(), + msg: EventMsg::UndoCompleted(UndoCompletedEvent { + success: true, + message: None, + }), + }); + + assert_eq!(chat.last_terminal_title, Some("Ready".to_string())); +} + +#[tokio::test] +async fn undo_started_refreshes_default_spinner_project_title() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = true; + chat.refresh_terminal_title(); + let project = chat + .last_terminal_title + .clone() + .expect("default title should include a project name"); + chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1); + + chat.handle_codex_event(Event { + id: "turn-undo".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), + }); + + assert_eq!(chat.last_terminal_title, Some(format!("⠋ {project}"))); +} + /// The commit picker shows only commit subjects (no timestamps). #[tokio::test] async fn review_commit_picker_shows_subjects_without_timestamps() { @@ -10505,16 +10559,20 @@ async fn status_line_invalid_items_warn_once() { ]); chat.thread_id = Some(ThreadId::new()); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one warning history cell"); let rendered = lines_to_single_string(&cells[0]); assert!( - rendered.contains("bogus_item"), + rendered.contains(r#""bogus_item""#), "warning cell missing invalid item content: {rendered}" ); + assert!( + !rendered.contains(r#"\"bogus_item\""#), + "warning cell should render plain quotes, not escaped quotes: {rendered}" + ); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); let cells = drain_insert_history(&mut rx); assert!( cells.is_empty(), @@ -10522,6 +10580,257 @@ async fn status_line_invalid_items_warn_once() { ); } +#[tokio::test] +async fn terminal_title_invalid_items_warn_once() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.tui_terminal_title = Some(vec![ + "status".to_string(), + "bogus_item".to_string(), + "bogus_item".to_string(), + ]); + chat.thread_id = Some(ThreadId::new()); + + chat.refresh_status_surfaces(); + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one warning history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains(r#""bogus_item""#), + "warning cell missing invalid item content: {rendered}" + ); + assert!( + !rendered.contains(r#"\"bogus_item\""#), + "warning cell should render plain quotes, not escaped quotes: {rendered}" + ); + + chat.refresh_status_surfaces(); + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected invalid terminal title warning to emit only once" + ); +} + +#[tokio::test] +async fn terminal_title_setup_cancel_reverts_live_preview() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let original = chat.config.tui_terminal_title.clone(); + + chat.open_terminal_title_setup(); + chat.preview_terminal_title(vec![TerminalTitleItem::Thread, TerminalTitleItem::Status]); + + assert_eq!( + chat.config.tui_terminal_title, + Some(vec!["thread".to_string(), "status".to_string()]) + ); + assert_eq!( + chat.terminal_title_setup_original_items, + Some(original.clone()) + ); + + chat.cancel_terminal_title_setup(); + + assert_eq!(chat.config.tui_terminal_title, original); + assert_eq!(chat.terminal_title_setup_original_items, None); +} + +#[tokio::test] +async fn terminal_title_status_uses_waiting_label_for_background_terminal_when_animations_disabled() +{ + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = false; + chat.on_task_started(); + terminal_interaction(&mut chat, "call-1", "proc-1", ""); + + assert_eq!(chat.terminal_title_status_text(), "Waiting"); +} + +#[tokio::test] +async fn terminal_title_status_uses_plain_labels_for_transient_states_when_animations_disabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = false; + + chat.mcp_startup_status = Some(std::collections::HashMap::new()); + assert_eq!(chat.terminal_title_status_text(), "Starting"); + + chat.mcp_startup_status = None; + chat.on_task_started(); + assert_eq!(chat.terminal_title_status_text(), "Working"); + + chat.handle_codex_event(Event { + id: "undo-1".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { + message: Some("Undoing changes".to_string()), + }), + }); + assert_eq!(chat.terminal_title_status_text(), "Undoing"); + + chat.on_agent_reasoning_delta("**Planning**\nmore".to_string()); + assert_eq!(chat.terminal_title_status_text(), "Thinking"); +} + +#[tokio::test] +async fn default_terminal_title_items_are_spinner_then_project() { + let (chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + assert_eq!( + chat.configured_terminal_title_items(), + vec!["spinner".to_string(), "project".to_string()] + ); +} + +#[tokio::test] +async fn terminal_title_can_render_app_name_item() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.tui_terminal_title = Some(vec!["app-name".to_string()]); + + chat.refresh_terminal_title(); + + assert_eq!(chat.last_terminal_title, Some("codex".to_string())); +} + +#[tokio::test] +async fn default_terminal_title_refreshes_when_spinner_state_changes() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = true; + + chat.config.tui_terminal_title = None; + let cwd = chat + .current_cwd + .clone() + .unwrap_or_else(|| chat.config.cwd.clone()); + let project = get_git_repo_root(&cwd) + .map(|root| { + root.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| format_directory_display(&root, None)) + }) + .or_else(|| { + chat.config + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .iter() + .find_map(|layer| match &layer.name { + ConfigLayerSource::Project { dot_codex_folder } => { + dot_codex_folder.as_path().parent().map(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| format_directory_display(path, None)) + }) + } + _ => None, + }) + }) + .unwrap_or_else(|| { + cwd.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| format_directory_display(&cwd, None)) + }); + chat.last_terminal_title = Some(project.clone()); + chat.bottom_pane.set_task_running(true); + chat.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; + chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1); + + chat.refresh_terminal_title(); + + assert_eq!(chat.last_terminal_title, Some(format!("⠋ {project}"))); +} + +#[tokio::test] +async fn terminal_title_spinner_item_renders_when_animations_enabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_task_running(true); + chat.terminal_title_status_kind = TerminalTitleStatusKind::Working; + chat.terminal_title_animation_origin = Instant::now(); + + assert_eq!( + chat.terminal_title_spinner_text_at(chat.terminal_title_animation_origin), + Some("⠋".to_string()) + ); + assert_eq!( + chat.terminal_title_spinner_text_at( + chat.terminal_title_animation_origin + TERMINAL_TITLE_SPINNER_INTERVAL, + ), + Some("⠙".to_string()) + ); +} + +#[tokio::test] +async fn terminal_title_uses_spaces_around_spinner_item() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = true; + chat.config.tui_terminal_title = Some(vec![ + "project".to_string(), + "spinner".to_string(), + "status".to_string(), + "thread".to_string(), + ]); + chat.thread_name = Some("Investigate flaky test".to_string()); + chat.bottom_pane.set_task_running(true); + chat.terminal_title_status_kind = TerminalTitleStatusKind::Working; + chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1); + + chat.refresh_terminal_title(); + + let title = chat + .last_terminal_title + .clone() + .expect("expected terminal title"); + assert!(title.contains(" ⠋ Working | ")); + assert!(!title.contains("| ⠋")); + assert!(!title.contains("⠋ |")); +} + +#[tokio::test] +async fn terminal_title_shows_spinner_and_undoing_without_task_running() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = true; + chat.config.tui_terminal_title = Some(vec!["spinner".to_string(), "status".to_string()]); + chat.terminal_title_status_kind = TerminalTitleStatusKind::Undoing; + chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1); + + assert!(!chat.bottom_pane.is_task_running()); + + chat.refresh_terminal_title(); + + assert_eq!(chat.last_terminal_title, Some("⠋ Undoing".to_string())); +} + +#[tokio::test] +async fn terminal_title_reschedules_spinner_when_title_text_is_unchanged() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (frame_requester, mut frame_schedule_rx) = FrameRequester::test_observable(); + chat.frame_requester = frame_requester; + chat.config.animations = true; + chat.config.tui_terminal_title = Some(vec!["spinner".to_string()]); + chat.bottom_pane.set_task_running(true); + chat.terminal_title_status_kind = TerminalTitleStatusKind::Working; + chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1); + chat.last_terminal_title = Some("⠋".to_string()); + + chat.refresh_terminal_title(); + + assert!(frame_schedule_rx.try_recv().is_ok()); +} + +#[tokio::test] +async fn on_task_started_resets_terminal_title_task_progress() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.last_plan_progress = Some((2, 5)); + + chat.on_task_started(); + + assert_eq!(chat.last_plan_progress, None); + assert_eq!(chat.terminal_title_task_progress(), None); +} + +#[test] +fn terminal_title_part_truncation_preserves_grapheme_clusters() { + let value = "ab👩‍💻cdefg".to_string(); + let truncated = ChatWidget::truncate_terminal_title_part(value, 7); + assert_eq!(truncated, "ab👩‍💻c..."); +} + #[tokio::test] async fn status_line_branch_state_resets_when_git_branch_disabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -10530,7 +10839,7 @@ async fn status_line_branch_state_resets_when_git_branch_disabled() { chat.status_line_branch_lookup_complete = true; chat.config.tui_status_line = Some(vec!["model_name".to_string()]); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); assert_eq!(chat.status_line_branch, None); assert!(!chat.status_line_branch_pending); @@ -10555,6 +10864,25 @@ async fn status_line_branch_refreshes_after_turn_complete() { assert!(chat.status_line_branch_pending); } +#[tokio::test] +async fn status_line_branch_refreshes_after_turn_complete_when_terminal_title_uses_git_branch() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.tui_status_line = Some(Vec::new()); + chat.config.tui_terminal_title = Some(vec!["git-branch".to_string()]); + chat.status_line_branch_lookup_complete = true; + chat.status_line_branch_pending = false; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + assert!(chat.status_line_branch_pending); +} + #[tokio::test] async fn status_line_branch_refreshes_after_interrupt() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -10578,11 +10906,11 @@ async fn status_line_fast_mode_renders_on_and_off() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); assert_eq!(status_line_text(&chat), Some("Fast off".to_string())); chat.set_service_tier(Some(ServiceTier::Fast)); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); assert_eq!(status_line_text(&chat), Some("Fast on".to_string())); } @@ -10595,7 +10923,7 @@ async fn status_line_fast_mode_footer_snapshot() { chat.show_welcome_banner = false; chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]); chat.set_service_tier(Some(ServiceTier::Fast)); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); let width = 80; let height = chat.desired_height(width); @@ -10618,7 +10946,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); chat.set_service_tier(Some(ServiceTier::Fast)); set_chatgpt_auth(&mut chat); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); assert_eq!( status_line_text(&chat), @@ -10626,7 +10954,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { ); chat.set_model("gpt-5.3-codex"); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); assert_eq!( status_line_text(&chat), @@ -10650,7 +10978,7 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() { chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); chat.set_service_tier(Some(ServiceTier::Fast)); set_chatgpt_auth(&mut chat); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); let width = 80; let height = chat.desired_height(width); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5ecb87dd27..b4841a7797 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -125,6 +125,7 @@ mod status_indicator_widget; mod streaming; mod style; mod terminal_palette; +mod terminal_title; mod text_formatting; mod theme_picker; mod tooltips; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index d83135c2ff..d30eeb2f45 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -38,6 +38,7 @@ pub enum SlashCommand { Mention, Status, DebugConfig, + Title, Statusline, Theme, Mcp, @@ -85,6 +86,7 @@ impl SlashCommand { SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", + SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", SlashCommand::Theme => "choose a syntax highlighting theme", SlashCommand::Ps => "list background terminals", @@ -177,6 +179,7 @@ impl SlashCommand { SlashCommand::Agent | SlashCommand::MultiAgents => true, SlashCommand::Statusline => false, SlashCommand::Theme => false, + SlashCommand::Title => false, } } diff --git a/codex-rs/tui/src/terminal_title.rs b/codex-rs/tui/src/terminal_title.rs new file mode 100644 index 0000000000..e4f009cb0d --- /dev/null +++ b/codex-rs/tui/src/terminal_title.rs @@ -0,0 +1,205 @@ +//! Terminal-title output helpers for the TUI. +//! +//! This module owns the low-level OSC title write path and the sanitization +//! that happens immediately before we emit it. It is intentionally narrow: +//! callers decide when the title should change and whether an empty title means +//! "leave the old title alone" or "clear the title Codex last wrote". +//! This module does not attempt to read or restore the terminal's previous +//! title because that is not portable across terminals. +//! +//! Sanitization is necessary because title content is assembled from untrusted +//! text sources such as model output, thread names, project paths, and config. +//! Before we place that text inside an OSC sequence, we strip: +//! - control characters that could terminate or reshape the escape sequence +//! - bidi/invisible formatting codepoints that can visually reorder or hide +//! text (the same family of issues discussed in Trojan Source writeups) +//! - redundant whitespace that would make titles noisy or hard to scan + +use std::fmt; +use std::io; +use std::io::IsTerminal; +use std::io::stdout; + +use crossterm::Command; +use ratatui::crossterm::execute; + +const MAX_TERMINAL_TITLE_CHARS: usize = 240; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum SetTerminalTitleResult { + /// A sanitized title was written, or stdout is not a terminal so no write was needed. + Applied, + /// Sanitization removed every visible character, so no title was emitted. + /// + /// This is distinct from clearing the title. Callers decide whether an + /// empty post-sanitization value should result in no-op behavior, clearing + /// the title Codex manages, or some other fallback. + NoVisibleContent, +} + +/// Writes a sanitized OSC window-title sequence to stdout. +/// +/// The input is treated as untrusted display text: control characters, +/// invisible formatting characters, and redundant whitespace are removed before +/// the title is emitted. If sanitization removes all visible content, the +/// function returns [`SetTerminalTitleResult::NoVisibleContent`] instead of +/// clearing the title because clearing and restoring are policy decisions for +/// higher-level callers. Mechanically, sanitization collapses whitespace runs +/// to single spaces, drops disallowed codepoints, and bounds the result to +/// [`MAX_TERMINAL_TITLE_CHARS`] visible characters before writing OSC 0. +pub(crate) fn set_terminal_title(title: &str) -> io::Result { + if !stdout().is_terminal() { + return Ok(SetTerminalTitleResult::Applied); + } + + let title = sanitize_terminal_title(title); + if title.is_empty() { + return Ok(SetTerminalTitleResult::NoVisibleContent); + } + + execute!(stdout(), SetWindowTitle(title))?; + Ok(SetTerminalTitleResult::Applied) +} + +/// Clears the current terminal title by writing an empty OSC title payload. +/// +/// This clears the visible title; it does not restore whatever title the shell +/// or a previous program may have set before Codex started managing the title. +pub(crate) fn clear_terminal_title() -> io::Result<()> { + if !stdout().is_terminal() { + return Ok(()); + } + + execute!(stdout(), SetWindowTitle(String::new())) +} + +#[derive(Debug, Clone)] +struct SetWindowTitle(String); + +impl Command for SetWindowTitle { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + // xterm/ctlseqs documents OSC 0/2 title sequences with ST (ESC \) termination. + // Most terminals also accept BEL for compatibility, but ST is the canonical form. + write!(f, "\x1b]0;{}\x1b\\", self.0) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Err(std::io::Error::other( + "tried to execute SetWindowTitle using WinAPI; use ANSI instead", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +/// Normalizes untrusted title text into a single bounded display line. +/// +/// This removes terminal control characters, strips invisible/bidi formatting +/// characters, collapses any whitespace run into a single ASCII space, and +/// truncates after [`MAX_TERMINAL_TITLE_CHARS`] emitted characters. +fn sanitize_terminal_title(title: &str) -> String { + let mut sanitized = String::new(); + let mut chars_written = 0; + let mut pending_space = false; + + for ch in title.chars() { + if ch.is_whitespace() { + pending_space = !sanitized.is_empty(); + continue; + } + + if is_disallowed_terminal_title_char(ch) { + continue; + } + + if pending_space && chars_written < MAX_TERMINAL_TITLE_CHARS { + sanitized.push(' '); + chars_written += 1; + pending_space = false; + } + + if chars_written >= MAX_TERMINAL_TITLE_CHARS { + break; + } + + sanitized.push(ch); + chars_written += 1; + } + + sanitized +} + +/// Returns whether `ch` should be dropped from terminal-title output. +/// +/// This includes both plain control characters and a curated set of invisible +/// formatting codepoints. The bidi entries here cover the Trojan-Source-style +/// text-reordering controls that can make a title render misleadingly relative +/// to its underlying byte sequence. +fn is_disallowed_terminal_title_char(ch: char) -> bool { + if ch.is_control() { + return true; + } + + // Strip Trojan-Source-related bidi controls plus common non-rendering + // formatting characters so title text cannot smuggle terminal control + // semantics or visually misleading content. + matches!( + ch, + '\u{00AD}' + | '\u{034F}' + | '\u{061C}' + | '\u{180E}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{206F}' + | '\u{FE00}'..='\u{FE0F}' + | '\u{FEFF}' + | '\u{FFF9}'..='\u{FFFB}' + | '\u{1BCA0}'..='\u{1BCA3}' + | '\u{E0100}'..='\u{E01EF}' + ) +} + +#[cfg(test)] +mod tests { + use super::MAX_TERMINAL_TITLE_CHARS; + use super::SetWindowTitle; + use super::sanitize_terminal_title; + use crossterm::Command; + use pretty_assertions::assert_eq; + + #[test] + fn sanitizes_terminal_title() { + let sanitized = + sanitize_terminal_title(" Project\t|\nWorking\x1b\x07\u{009D}\u{009C} | Thread "); + assert_eq!(sanitized, "Project | Working | Thread"); + } + + #[test] + fn strips_invisible_format_chars_from_terminal_title() { + let sanitized = sanitize_terminal_title( + "Pro\u{202E}j\u{2066}e\u{200F}c\u{061C}t\u{200B} \u{FEFF}T\u{2060}itle", + ); + assert_eq!(sanitized, "Project Title"); + } + + #[test] + fn truncates_terminal_title() { + let input = "a".repeat(MAX_TERMINAL_TITLE_CHARS + 10); + let sanitized = sanitize_terminal_title(&input); + assert_eq!(sanitized.len(), MAX_TERMINAL_TITLE_CHARS); + } + + #[test] + fn writes_osc_title_with_string_terminator() { + let mut out = String::new(); + SetWindowTitle("hello".to_string()) + .write_ansi(&mut out) + .expect("encode terminal title"); + assert_eq!(out, "\x1b]0;hello\x1b\\"); + } +} diff --git a/codex-rs/tui/src/tui/frame_requester.rs b/codex-rs/tui/src/tui/frame_requester.rs index d7e54d82cc..8c2b43b08a 100644 --- a/codex-rs/tui/src/tui/frame_requester.rs +++ b/codex-rs/tui/src/tui/frame_requester.rs @@ -65,6 +65,17 @@ impl FrameRequester { frame_schedule_tx: tx, } } + + /// Create a requester and expose its raw schedule queue for assertions. + pub(crate) fn test_observable() -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + ( + FrameRequester { + frame_schedule_tx: tx, + }, + rx, + ) + } } /// A scheduler for coalescing frame draw requests and notifying the TUI event loop. diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 262cd4216a..2f3118a8b4 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -870,6 +870,7 @@ fn turn_snapshot_events( }), ); } + TurnItem::HookPrompt(_) => {} } } @@ -1010,6 +1011,7 @@ fn thread_item_to_core(item: &ThreadItem) -> Option { | ThreadItem::McpToolCall { .. } | ThreadItem::DynamicToolCall { .. } | ThreadItem::CollabAgentToolCall { .. } + | ThreadItem::HookPrompt { .. } | ThreadItem::ImageView { .. } | ThreadItem::EnteredReviewMode { .. } | ThreadItem::ExitedReviewMode { .. } => { diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index f91ebbaea4..d76751c146 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -5733,6 +5733,7 @@ impl ChatWidget { ThreadItem::ContextCompaction { .. } => { self.on_agent_message("Context compacted".to_owned()); } + ThreadItem::HookPrompt { .. } => {} ThreadItem::CollabAgentToolCall { id, tool, diff --git a/tools/argument-comment-lint/README.md b/tools/argument-comment-lint/README.md index 82e5605e36..91c1fdecc8 100644 --- a/tools/argument-comment-lint/README.md +++ b/tools/argument-comment-lint/README.md @@ -68,6 +68,11 @@ cd tools/argument-comment-lint cargo test ``` +GitHub releases also publish a DotSlash file named +`argument-comment-lint` for macOS arm64, Linux arm64, Linux x64, and Windows +x64. The published package contains a small runner executable, a bundled +`cargo-dylint`, and the prebuilt lint library. + Run the lint against `codex-rs` from the repo root: ```bash diff --git a/tools/argument-comment-lint/src/bin/argument-comment-lint.rs b/tools/argument-comment-lint/src/bin/argument-comment-lint.rs new file mode 100644 index 0000000000..e3175efe53 --- /dev/null +++ b/tools/argument-comment-lint/src/bin/argument-comment-lint.rs @@ -0,0 +1,164 @@ +use std::env; +use std::ffi::OsString; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::ExitCode; + +fn main() -> ExitCode { + match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result { + let exe_path = + env::current_exe().map_err(|err| format!("failed to locate current executable: {err}"))?; + let bin_dir = exe_path.parent().ok_or_else(|| { + format!( + "failed to locate parent directory for executable {}", + exe_path.display() + ) + })?; + let package_root = bin_dir.parent().ok_or_else(|| { + format!( + "failed to locate package root for executable {}", + exe_path.display() + ) + })?; + let cargo_dylint = bin_dir.join(cargo_dylint_binary_name()); + let library_dir = package_root.join("lib"); + let library_path = find_bundled_library(&library_dir)?; + + ensure_exists(&cargo_dylint, "bundled cargo-dylint executable")?; + ensure_exists( + &library_dir, + "bundled argument-comment lint library directory", + )?; + + let args: Vec = env::args_os().skip(1).collect(); + let mut command = Command::new(&cargo_dylint); + command.arg("dylint"); + command.arg("--lib-path").arg(&library_path); + if !has_library_selection(&args) { + command.arg("--all"); + } + command.args(&args); + set_default_env(&mut command); + + let status = command + .status() + .map_err(|err| format!("failed to execute {}: {err}", cargo_dylint.display()))?; + Ok(exit_code_from_status(status.code())) +} + +fn has_library_selection(args: &[OsString]) -> bool { + let mut expect_value = false; + for arg in args { + if expect_value { + return true; + } + + match arg.to_string_lossy().as_ref() { + "--" => break, + "--lib" | "--lib-path" => { + expect_value = true; + } + "--lib=" | "--lib-path=" => return true, + value if value.starts_with("--lib=") || value.starts_with("--lib-path=") => { + return true; + } + _ => {} + } + } + + false +} + +fn set_default_env(command: &mut Command) { + if let Some(flags) = env::var_os("DYLINT_RUSTFLAGS") { + let mut flags = flags.to_string_lossy().to_string(); + append_flag_if_missing(&mut flags, "-D uncommented-anonymous-literal-argument"); + append_flag_if_missing(&mut flags, "-A unknown_lints"); + command.env("DYLINT_RUSTFLAGS", flags); + } else { + command.env( + "DYLINT_RUSTFLAGS", + "-D uncommented-anonymous-literal-argument -A unknown_lints", + ); + } + + if env::var_os("CARGO_INCREMENTAL").is_none() { + command.env("CARGO_INCREMENTAL", "0"); + } +} + +fn append_flag_if_missing(flags: &mut String, flag: &str) { + if flags.contains(flag) { + return; + } + + if !flags.is_empty() { + flags.push(' '); + } + flags.push_str(flag); +} + +fn cargo_dylint_binary_name() -> &'static str { + if cfg!(windows) { + "cargo-dylint.exe" + } else { + "cargo-dylint" + } +} + +fn ensure_exists(path: &Path, label: &str) -> Result<(), String> { + if path.exists() { + Ok(()) + } else { + Err(format!("{label} not found at {}", path.display())) + } +} + +fn find_bundled_library(library_dir: &Path) -> Result { + let entries = fs::read_dir(library_dir).map_err(|err| { + format!( + "failed to read bundled library directory {}: {err}", + library_dir.display() + ) + })?; + let mut candidates = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .filter(|path| { + path.file_name() + .map(|name| name.to_string_lossy().contains('@')) + .unwrap_or(false) + }); + + let Some(first) = candidates.next() else { + return Err(format!( + "no packaged Dylint library found in {}", + library_dir.display() + )); + }; + if candidates.next().is_some() { + return Err(format!( + "expected exactly one packaged Dylint library in {}", + library_dir.display() + )); + } + + Ok(first) +} + +fn exit_code_from_status(code: Option) -> ExitCode { + code.and_then(|value| u8::try_from(value).ok()) + .map_or_else(|| ExitCode::from(1), ExitCode::from) +}